diff --git a/.claude/launch.json b/.claude/launch.json index 833db8f15a..fd66523cc4 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -16,31 +16,19 @@ { "name": "next-playground", "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "--filter", - "@orpc/next-playground", - "dev" - ], + "runtimeArgs": ["--filter", "@orpc/next-playground", "dev"], "port": 3000 }, { "name": "nest-playground", "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "--filter", - "@orpc/nest-playground", - "dev" - ], + "runtimeArgs": ["--filter", "@orpc/nest-playground", "dev"], "port": 3000 }, { "name": "cloudflare-playground", "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "--filter", - "@orpc/cloudflare-playground", - "dev" - ], + "runtimeArgs": ["--filter", "@orpc/cloudflare-playground", "dev"], "port": 3000 } ] diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index a97d6a6df6..0000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "jq -r '.tool_input.file_path // .tool_response.filePath' | { read -r f; ./node_modules/.bin/eslint --no-warn-ignored --fix \"$f\"; } 2>/dev/null || true", - "timeout": 30, - "statusMessage": "Running eslint --fix" - } - ] - } - ] - } -} diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml index 2e2b014834..bdb4ffe8e9 100644 --- a/.github/ISSUE_TEMPLATE/1-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -17,8 +17,7 @@ body: id: reproduction attributes: label: Reproduction - description: - Please link to a minimal repository that reproduces the issue. + description: Please link to a minimal repository that reproduces the issue. A minimal repro is required unless the problem is obvious from your description. You can create one with [oRPC Playgrounds](https://orpc.dev/docs/playgrounds). placeholder: Reproduction @@ -28,8 +27,7 @@ body: id: bug-description attributes: label: Describe the bug - description: - Provide a clear, concise description of the bug. + description: Provide a clear, concise description of the bug. If you plan to submit a PR to fix it, mention that here. Thanks! placeholder: Bug description validations: diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.yml b/.github/ISSUE_TEMPLATE/2-feature-request.yml index cc66eb4eb5..c424ab702e 100644 --- a/.github/ISSUE_TEMPLATE/2-feature-request.yml +++ b/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -9,8 +9,7 @@ body: id: feature-description attributes: label: Describe the feature - description: - A clear and concise description of what you think would be a helpful addition, + description: A clear and concise description of what you think would be a helpful addition, including the possible use cases and alternatives you have considered. If you have a working prototype or module that implements it, please include a link. placeholder: Feature description diff --git a/.github/ISSUE_TEMPLATE/3-documentation.yml b/.github/ISSUE_TEMPLATE/3-documentation.yml index 7925fbdcdb..d1471b870a 100644 --- a/.github/ISSUE_TEMPLATE/3-documentation.yml +++ b/.github/ISSUE_TEMPLATE/3-documentation.yml @@ -9,8 +9,7 @@ body: id: documentation-description attributes: label: Describe the change - description: - A clear and concise description of what you think should be changed, + description: A clear and concise description of what you think should be changed, including the possible use cases and alternatives you have considered. placeholder: Description validations: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 02760703e7..0da2b94218 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,21 +10,14 @@ updates: default-days: 1 versioning-strategy: increase groups: - eslint: - patterns: - - eslint - - '@antfu/eslint-config' - - 'eslint-plugin-*' standard-server: patterns: - '@standard-server/*' + openapi-spec: + patterns: + - '@openapi-spec/*' dev-dependencies-minor-patch: dependency-type: development - exclude-patterns: - - eslint - - '@antfu/eslint-config' - - 'eslint-plugin-*' - - compression # inline update-types: - minor - patch diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 35cbe1180f..52c4911856 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ permissions: contents: read jobs: - lint_and_typecheck: + check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -19,9 +19,7 @@ jobs: - run: pnpm i - - run: pnpm run lint - - - run: pnpm run type:check + - run: pnpm run check - run: pnpm run docs:validate diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index 19401170c0..d5030252f8 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -34,8 +34,7 @@ jobs: 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 }} + 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 }} diff --git a/.gitignore b/.gitignore index 7832c07d62..9aaefde0d3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ !.claude/ !.agents/ !.zed/ +.claude/worktrees/ # Common generated folders logs/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..99e2f7ddf7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oxc.oxc-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index da747cd721..6599cf4f52 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,55 +1,7 @@ { - // Disable the default formatter, use eslint instead - "prettier.enable": false, - "editor.formatOnSave": false, - - // Auto fix + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit", - "source.organizeImports": "never" - }, - - // Silent the stylistic rules in you IDE, but still auto fix them - "eslint.rules.customizations": [ - { "rule": "style/*", "severity": "off", "fixable": true }, - { "rule": "format/*", "severity": "off", "fixable": true }, - { "rule": "*-indent", "severity": "off", "fixable": true }, - { "rule": "*-spacing", "severity": "off", "fixable": true }, - { "rule": "*-spaces", "severity": "off", "fixable": true }, - { "rule": "*-order", "severity": "off", "fixable": true }, - { "rule": "*-dangle", "severity": "off", "fixable": true }, - { "rule": "*-newline", "severity": "off", "fixable": true }, - { "rule": "*quotes", "severity": "off", "fixable": true }, - { "rule": "*semi", "severity": "off", "fixable": true } - ], - - // Enable eslint for all supported languages - "eslint.validate": [ - "javascript", - "javascriptreact", - "typescript", - "typescriptreact", - "vue", - "html", - "markdown", - "json", - "jsonc", - "yaml", - "toml", - "xml", - "gql", - "graphql", - "astro", - "svelte", - "css", - "less", - "scss", - "pcss", - "postcss" - ], - - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true, - "typescript.preferences.autoImportFileExcludePatterns": [], - "typescript.preferences.autoImportSpecifierExcludeRegexes": [] + "source.fixAll.oxc": "explicit" + } } diff --git a/.zed/settings.json b/.zed/settings.json index 73d48d25e8..2693c2e35c 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,55 +1,4 @@ { "format_on_save": "on", - // Use ESLint's --fix: - "code_actions_on_format": { - "source.fixAll.eslint": true - }, - "formatter": [], - // Enable eslint for all supported languages - // Defaults only include https://github.com/search?q=repo%3Azed-industries%2Fzed%20eslint_languages&type=code - "languages": { - "HTML": { - "language_servers": ["...", "eslint"] - }, - "Markdown": { - "language_servers": ["...", "eslint"] - }, - "Markdown-Inline": { - "language_servers": ["...", "eslint"] - }, - "JSON": { - "language_servers": ["...", "eslint"] - }, - "JSONC": { - "language_servers": ["...", "eslint"] - }, - "YAML": { - "language_servers": ["...", "eslint"] - }, - "CSS": { - "language_servers": ["...", "eslint"] - } - // Add other languages as needed - }, - "lsp": { - "eslint": { - "settings": { - "workingDirectories": ["./"], - - // Silent the stylistic rules in your IDE, but still auto fix them - "rulesCustomizations": [ - { "rule": "style/*", "severity": "off", "fixable": true }, - { "rule": "format/*", "severity": "off", "fixable": true }, - { "rule": "*-indent", "severity": "off", "fixable": true }, - { "rule": "*-spacing", "severity": "off", "fixable": true }, - { "rule": "*-spaces", "severity": "off", "fixable": true }, - { "rule": "*-order", "severity": "off", "fixable": true }, - { "rule": "*-dangle", "severity": "off", "fixable": true }, - { "rule": "*-newline", "severity": "off", "fixable": true }, - { "rule": "*quotes", "severity": "off", "fixable": true }, - { "rule": "*semi", "severity": "off", "fixable": true } - ] - } - } - } + "formatter": [{ "code_action": "source.fixAll.oxc" }, { "language_server": { "name": "oxfmt" } }] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..3901bfceed --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md + +## Repository + +oRPC: typesafe APIs with RPC and OpenAPI support, in a pnpm monorepo. + +- `packages/*`: the published packages; `packages/bun` runs with `bun test`, `packages/cloudflare` with its own vitest (workerd) +- `apps/content`: the docs site (Blume) +- `tests/`: e2e tests +- `playgrounds/*`: example apps +- `benches/`: benchmarks + +## Commands + +```bash +pnpm install +pnpm test # per-package tests + vitest +pnpm vitest run # one test file +pnpm check # lint and type check, including *.test-d.ts +pnpm fix # check, applying fixes +pnpm format # format with oxfmt +pnpm repo:fix # fix monorepo issues with sherif +pnpm docs:validate # JSDoc backlinks + docs links +pnpm bench +pnpm --filter @orpc/server prepack # build one package; tests don't need a build +``` + +Before finishing, run `pnpm format`, `pnpm check` and `pnpm test`. CI doesn't check formatting. + +## Pull requests + +Titles follow Conventional Commits, scoped by the affected packages, separated by a comma and a space: `fix(server): ...`, `fix(server, client): ...`. Use `rpc`/`openapi` for changes tied to a protocol, and `content` only with `chore`, for the docs site itself; docs about a package use its scope (`docs(openapi): ...`). Leave out the scope when the change is too broad. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index fd402d8bf5..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,29 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Repository - -oRPC — typesafe API framework. pnpm monorepo (Node 22+, `pnpm` workspaces) with packages in `packages/*`, docs/website in `apps/content` (Blume), example apps in `playgrounds/*`, root-level e2e tests in `tests/`, and benchmarks in `benches/`. - -## Commands - -```bash -pnpm install # setup -pnpm test # all tests: per-package tests + root vitest -pnpm vitest run # single test file (from repo root), e.g. pnpm vitest run packages/server/src/builder.test.ts -pnpm type:check # tsc across all packages — this is also what runs the *.test-d.ts type tests -pnpm lint # eslint (also the formatter — @antfu/eslint-config) -pnpm lint:fix -pnpm bench # vitest bench -pnpm --filter @orpc/server prepack # build one package (obuild, runs automatically on pack/publish); builds are NOT needed for dev/tests -``` - -- Root vitest config (`vitest.config.ts`) runs all `*.test.ts` with `globals: true`, plus a jsdom project for `*.test.tsx` in `packages/next`, `packages/tanstack-query`, `packages/pinia-colada` and `packages/swr`. -- Exceptions: `packages/bun` runs with `bun test`, `packages/cloudflare` with its own vitest (workerd) — both are excluded from the root vitest and root tsconfig. Run their tests via `pnpm --filter test`. `packages/nest` is excluded only from the root tsconfig (type-check it with `pnpm --filter @orpc/nest type:check`); its tests run in the root vitest. -- Builds use obuild's transform mode (`build.config.ts`). Exported values need explicit types (`isolatedDeclarations`); mark side-effect-free module-level calls `/* @__PURE__ */`. `verifyDist` (`scripts/build-utils.ts`) fails `prepack` on undeclared imports and import cycles. -- Docs site (Blume): `cd apps/content && pnpm dev` (`blume dev`); `pnpm build` runs `blume build`. Content lives in `.mdx` files; custom pages in `apps/content/pages/`. The dev server caches content-collection frontmatter for custom pages — restart it after editing blog-post frontmatter. `pnpm docs:validate` (root) runs the JSDoc backlink checker plus `blume validate --strict` (links, anchors, assets); CI runs it too. -- PR titles follow Conventional Commits. Scope rules: - - Use the package name (e.g. `feat(server): ...`), or `rpc`/`openapi` for changes tied to a protocol rather than a single package, such as its serializers, handlers, links, or dedicated plugins. - - Use `content` only for the docs site itself (styles, Blume config, ...), and only with the `chore` type — never `feat`/`fix`/`perf`/`docs`, since these changes do not affect library users. Documentation about a package or protocol uses that package/protocol scope instead (e.g. `docs(openapi): ...`). - - Omit the scope when the change is too broad for a single one. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8be111550a..84e5832eab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ This repository uses: - **Node.js** v22+ (use [`pnpm env`](https://pnpm.io/cli/env) for quick setup) - **pnpm** and **pnpm workspaces** for dependency management - **Vitest** for testing -- **ESLint** with [@antfu/eslint-config](https://github.com/antfu/eslint-config) for linting and formatting +- **oxlint** for linting and type checking (`pnpm check`), **oxfmt** for formatting (`pnpm format`) ## Workflow diff --git a/README.md b/README.md index ec8833451c..2a0c2e2e15 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx b/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx index 1982826af3..ea8f6bbd09 100644 --- a/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx +++ b/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx @@ -1,16 +1,16 @@ --- -title: "Next.js SSR with oRPC and TanStack Query: From Zero to Live Queries" -description: "Build a server-rendered Next.js app with oRPC and TanStack Query step by step, including streamed and live queries that resume in the browser." +title: 'Next.js SSR with oRPC and TanStack Query: From Zero to Live Queries' +description: 'Build a server-rendered Next.js app with oRPC and TanStack Query step by step, including streamed and live queries that resume in the browser.' type: blog date: 2026-08-19 search: tags: - Tutorials authors: - name: Định Lê - role: Creator of oRPC - url: https://github.com/dinwwwh - avatar: https://github.com/dinwwwh.png + name: Định Lê + role: Creator of oRPC + url: https://github.com/dinwwwh + avatar: https://github.com/dinwwwh.png ai: exclude: true --- @@ -66,36 +66,35 @@ const PLANETS = [ { id: 3, name: 'Venus', description: 'Pretty from a distance' }, ] -const listPlanets = os - .handler(async () => { - // replace with your database query - return PLANETS - }) +const listPlanets = os.handler(async () => { + // replace with your database query + return PLANETS +}) -const planetFeed = os - .handler(async function* ({ signal }) { - const names = ['Kepler-186f', 'TRAPPIST-1e', 'Proxima b', 'Gliese 581g'] +const planetFeed = os.handler(async function* ({ signal }) { + const names = ['Kepler-186f', 'TRAPPIST-1e', 'Proxima b', 'Gliese 581g'] - for (let i = 0; ; i++) { - signal?.throwIfAborted() - yield { id: i, name: names[i % names.length], at: new Date() } - await new Promise(resolve => setTimeout(resolve, 2000)) - } - }) + for (let i = 0; ; i++) { + signal?.throwIfAborted() + yield { id: i, name: names[i % names.length], at: new Date() } + await new Promise((resolve) => setTimeout(resolve, 2000)) + } +}) -const planetTelemetry = os - .input(z.object({ planet: z.string() })) - .handler(async function* ({ input, signal }) { - while (true) { - signal?.throwIfAborted() - yield { - planet: input.planet, - temperature: Math.round(15 + Math.random() * 10), - at: new Date(), - } - await new Promise(resolve => setTimeout(resolve, 1000)) +const planetTelemetry = os.input(z.object({ planet: z.string() })).handler(async function* ({ + input, + signal, +}) { + while (true) { + signal?.throwIfAborted() + yield { + planet: input.planet, + temperature: Math.round(15 + Math.random() * 10), + at: new Date(), } - }) + await new Promise((resolve) => setTimeout(resolve, 1000)) + } +}) export const router = { planet: { @@ -106,7 +105,7 @@ export const router = { } ``` -The interesting part is the [async generator functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function*) (`async function*`). Every `yield` sends one event to the client over [Server-Sent Events](/docs/async-iterator-object), and `signal` aborts the loop when the client disconnects. The client decides how to consume the stream: the feed will *append* events into a list, telemetry will *replace* the previous value. +The interesting part is the [async generator functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function*) (`async function*`). Every `yield` sends one event to the client over [Server-Sent Events](/docs/async-iterator-object), and `signal` aborts the loop when the client disconnects. The client decides how to consume the stream: the feed will _append_ events into a list, telemetry will _replace_ the previous value. Also notice `at: new Date()`. oRPC's [RPC protocol](/docs/rpc/serializer) preserves types that plain JSON cannot represent, such as `Date`, `Map`, `Set`, and `BigInt`, so the client receives a real `Date` object. @@ -184,7 +183,7 @@ Calling your own HTTP endpoint during SSR works fine for this tutorial, but it d ## A Query Client That Survives SSR -Here is the 30-second version of how TanStack Query SSR works: the server renders your components, runs their queries, and serializes the query cache into the HTML. The browser then *hydrates* that cache and takes over, without refetching anything. For the full picture, read the [TanStack Query SSR guide](https://tanstack.com/query/latest/docs/framework/react/guides/ssr). +Here is the 30-second version of how TanStack Query SSR works: the server renders your components, runs their queries, and serializes the query cache into the HTML. The browser then _hydrates_ that cache and takes over, without refetching anything. For the full picture, read the [TanStack Query SSR guide](https://tanstack.com/query/latest/docs/framework/react/guides/ssr). That handoff needs a carefully configured `QueryClient`. Create `lib/query-client.ts`: @@ -243,9 +242,7 @@ export function Providers(props: { children: React.ReactNode }) { return ( - - {props.children} - + {props.children} ) } @@ -291,11 +288,9 @@ export function PlanetList() { return (
    - {planets.map(planet => ( + {planets.map((planet) => (
  • - {planet.name} - {' '} - {planet.description} + {planet.name} {planet.description}
  • ))}
@@ -303,7 +298,7 @@ export function PlanetList() { } ``` -Note what you did *not* write: no query key, no fetch function, no response parsing, no result type. `orpc.planet.list.queryOptions()` derives all of it from the router, and `planets` is typed exactly as `listPlanets` returns. +Note what you did _not_ write: no query key, no fetch function, no response parsing, no result type. `orpc.planet.list.queryOptions()` derives all of it from the router, and `planets` is typed exactly as `listPlanets` returns. `useSuspenseQuery` (rather than `useQuery`) is what opts this component into SSR: suspense queries execute during server rendering, while plain `useQuery` stays browser-only. Now replace `app/page.tsx` with: @@ -344,10 +339,12 @@ import { useQuery } from '@tanstack/react-query' import { orpc } from '@/lib/orpc' export function DiscoveryFeed() { - const { data: events = [] } = useQuery(orpc.planet.feed.streamedOptions({ - queryFnOptions: { maxChunks: 10 }, // keep only the 10 newest events - retry: true, // reconnect forever if the stream drops - })) + const { data: events = [] } = useQuery( + orpc.planet.feed.streamedOptions({ + queryFnOptions: { maxChunks: 10 }, // keep only the 10 newest events + retry: true, // reconnect forever if the stream drops + }), + ) if (events.length === 0) { return

Scanning the sky...

@@ -355,15 +352,9 @@ export function DiscoveryFeed() { return (
    - {events.map(event => ( + {events.map((event) => (
  • - Discovered - {' '} - {event.name} - {' '} - at - {' '} - {event.at.toISOString()} + Discovered {event.name} at {event.at.toISOString()}
  • ))}
@@ -393,32 +384,27 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { orpc } from '@/lib/orpc' export function Telemetry() { - const { data: reading } = useSuspenseQuery(orpc.planet.telemetry.liveOptions({ - input: { planet: 'earth' }, - retry: true, // reconnect forever if the stream drops - })) + const { data: reading } = useSuspenseQuery( + orpc.planet.telemetry.liveOptions({ + input: { planet: 'earth' }, + retry: true, // reconnect forever if the stream drops + }), + ) return (

- {reading.planet} - {' '} - surface: - {' '} + {reading.planet} surface:{' '} {reading.temperature} °C - - {' '} - (updated - {' '} - {reading.at.toISOString()} - ) + {' '} + (updated {reading.at.toISOString()})

) } ``` -Where a streamed query accumulates events, `.liveOptions` keeps only the latest one: each `yield` *replaces* `data`. And since this is a `useSuspenseQuery`, it runs during SSR. +Where a streamed query accumulates events, `.liveOptions` keeps only the latest one: each `yield` _replaces_ `data`. And since this is a `useSuspenseQuery`, it runs during SSR. Wire it into the page, inside its own suspense boundary: diff --git a/apps/content/blog/stop-fetching-from-yourself.mdx b/apps/content/blog/stop-fetching-from-yourself.mdx index 2dda9084fd..0e97412bb8 100644 --- a/apps/content/blog/stop-fetching-from-yourself.mdx +++ b/apps/content/blog/stop-fetching-from-yourself.mdx @@ -1,16 +1,16 @@ --- -title: "Stop Making HTTP Requests to Yourself During SSR" -description: "Most fullstack apps fetch SSR data from their own API over HTTP. Call your API logic in-process instead, and share one client between server and browser." +title: 'Stop Making HTTP Requests to Yourself During SSR' +description: 'Most fullstack apps fetch SSR data from their own API over HTTP. Call your API logic in-process instead, and share one client between server and browser.' type: blog date: 2026-08-19 search: tags: - Engineering authors: - name: Định Lê - role: Creator of oRPC - url: https://github.com/dinwwwh - avatar: https://github.com/dinwwwh.png + name: Định Lê + role: Creator of oRPC + url: https://github.com/dinwwwh + avatar: https://github.com/dinwwwh.png ai: exclude: true --- @@ -34,7 +34,7 @@ The server needs data, so it opens a socket, serializes a request, sends it acro This works, but you pay for it on every render: - A full HTTP round trip: connection handling, header parsing, a second request lifecycle. -- On serverless platforms, a self-fetch can invoke a *second* function instance: extra cold starts, double billing, and a deadlock risk when concurrency is capped. +- On serverless platforms, a self-fetch can invoke a _second_ function instance: extra cold starts, double billing, and a deadlock risk when concurrency is capped. - Cookies and auth headers are not forwarded automatically. Everyone learns this the first time SSR renders the logged-out page for a logged-in user. - Relative URLs like `fetch('/api/planets')` fail on the server, so now you maintain a base-URL environment variable per deployment. @@ -61,8 +61,7 @@ export async function handleApi(request: Request): Promise { Nothing in that signature requires a network. `fetch` itself is just a function from a request to a promise of a response, so you can implement it with a direct call: ```ts -const internalFetch: typeof fetch = async (input, init) => - handleApi(new Request(input, init)) +const internalFetch: typeof fetch = async (input, init) => handleApi(new Request(input, init)) ``` Any code written against `fetch` now runs your API in-process: no socket, no port, no second request lifecycle. The origin in the URL becomes a formality, since it never leaves the process, which also kills the base-URL problem. And because you construct the internal `Request` yourself, passing the incoming request's headers along is one argument, not a forwarding layer. @@ -95,9 +94,10 @@ You want a single `client` import that every page uses, on the server and in the // ❌ looks fine, ships your database to the browser import { handleApi } from './api' -export const client = typeof window === 'undefined' - ? createClient(internalFetch, 'http://internal') - : createClient(fetch, window.location.origin) +export const client = + typeof window === 'undefined' + ? createClient(internalFetch, 'http://internal') + : createClient(fetch, window.location.origin) ``` The branch runs at runtime, but bundling happens at build time. The `import { handleApi }` line pulls your entire API, and everything it imports, such as the database driver and secrets handling, into the client module graph. Best case, your framework fails the build. Worst case, server code quietly ships to every visitor. @@ -119,8 +119,7 @@ declare global { * SSR uses the client registered on globalThis. * The browser falls back to a real fetch against the current origin. */ -export const client: Client = globalThis.$client - ?? createClient(fetch, window.location.origin) +export const client: Client = globalThis.$client ?? createClient(fetch, window.location.origin) ``` A separate server-only module creates the internal client and registers it: diff --git a/apps/content/blog/uploads-bigger-than-your-ram.mdx b/apps/content/blog/uploads-bigger-than-your-ram.mdx index b7815ba03d..7d8325019b 100644 --- a/apps/content/blog/uploads-bigger-than-your-ram.mdx +++ b/apps/content/blog/uploads-bigger-than-your-ram.mdx @@ -1,16 +1,16 @@ --- -title: "Handling File Uploads Bigger Than Your RAM in JavaScript" -description: "Stream large file uploads to disk in any JavaScript runtime while keeping the standard File API, with lazy blobs, temp file cleanup, and body size limits." +title: 'Handling File Uploads Bigger Than Your RAM in JavaScript' +description: 'Stream large file uploads to disk in any JavaScript runtime while keeping the standard File API, with lazy blobs, temp file cleanup, and body size limits.' type: blog date: 2026-08-17 search: tags: - Engineering authors: - name: Định Lê - role: Creator of oRPC - url: https://github.com/dinwwwh - avatar: https://github.com/dinwwwh.png + name: Định Lê + role: Creator of oRPC + url: https://github.com/dinwwwh + avatar: https://github.com/dinwwwh.png ai: exclude: true --- @@ -80,18 +80,14 @@ createServer(async (req, res) => { // constant memory, no matter how large the body is await pipeline(req, createWriteStream(tmpPath)) - const file = new TmpFile( - await openAsBlob(tmpPath), - tmpPath, - 'video.mp4', - { type: req.headers['content-type'] }, - ) + const file = new TmpFile(await openAsBlob(tmpPath), tmpPath, 'video.mp4', { + type: req.headers['content-type'], + }) // hand it to anything that expects a standard File console.log(file.name, file.size, file instanceof File) res.end('ok') - } - finally { + } finally { await rm(dir, { recursive: true, force: true }) } }).listen(3000) @@ -105,7 +101,7 @@ If this were the whole story, the plugin would be fifty lines. The piece of the Spooling bytes to disk is the easy 20%. Most of the plugin's design went into making them reliably disappear, and each problem applies to any implementation of this pattern. -**When is a request "done"?** Deleting when the handler returns is wrong: a streaming response, like an event stream or a raw binary stream, may read the upload *while* transmitting, and would fail halfway. So the plugin inspects the response: if its body is a stream or an async iterator, deletion rides on the body finishing. That also covers clients that vanish mid-download, since cancelling the body reaches the same finish hook. Every other response is sent after removal. Temp files live exactly as long as anything can read them, and not a request longer. +**When is a request "done"?** Deleting when the handler returns is wrong: a streaming response, like an event stream or a raw binary stream, may read the upload _while_ transmitting, and would fail halfway. So the plugin inspects the response: if its body is a stream or an async iterator, deletion rides on the body finishing. That also covers clients that vanish mid-download, since cancelling the body reaches the same finish hook. Every other response is sent after removal. Temp files live exactly as long as anything can read them, and not a request longer. **Open handles.** Each request gets its own directory under the OS temp dir, and content lands through self-contained appends instead of long-lived file handles: at most one transient descriptor per in-flight write, however many files the request carries. A burst of uploads cannot exhaust the descriptor table, and deletion never fights an open handle, which matters on Windows where deleting an open file is an error. @@ -144,13 +140,11 @@ const handler = new RPCHandler(router, { ], }) -const uploadVideo = os - .input(z.object({ video: z.file() })) - .handler(async ({ input }) => { - if (input.video instanceof TmpFile) { - await rename(input.video.path, `./videos/${crypto.randomUUID()}`) - } - }) +const uploadVideo = os.input(z.object({ video: z.file() })).handler(async ({ input }) => { + if (input.video instanceof TmpFile) { + await rename(input.video.path, `./videos/${crypto.randomUUID()}`) + } +}) ``` File bodies and multipart file parts spool to disk; JSON and text fields parse normally. Validation sees standard `File` objects, so schemas like `z.file()` just work, and `TmpFile.path` turns "save the upload" into a rename instead of a gigabyte copy. diff --git a/apps/content/blog/v1-announcement.mdx b/apps/content/blog/v1-announcement.mdx index da8e03c37d..99b0eb6d3a 100644 --- a/apps/content/blog/v1-announcement.mdx +++ b/apps/content/blog/v1-announcement.mdx @@ -1,16 +1,16 @@ --- -title: "oRPC v1 Announcement - Typesafe APIs Made Simple 🪄" -description: "oRPC v1 is out: a stable, production-ready way to build typesafe APIs in TypeScript, and an alternative to tRPC, ts-rest, next-safe-action, and more!" +title: 'oRPC v1 Announcement - Typesafe APIs Made Simple 🪄' +description: 'oRPC v1 is out: a stable, production-ready way to build typesafe APIs in TypeScript, and an alternative to tRPC, ts-rest, next-safe-action, and more!' type: blog date: 2025-04-16 search: tags: - Announcements authors: - name: Định Lê - role: Creator of oRPC - url: https://github.com/dinwwwh - avatar: https://github.com/dinwwwh.png + name: Định Lê + role: Creator of oRPC + url: https://github.com/dinwwwh + avatar: https://github.com/dinwwwh.png ai: exclude: true --- @@ -57,11 +57,11 @@ const getting = os .use(retry({ times: 3 })) .route({ method: 'GET', path: '/getting/{id}' }) .input(z.object({ id: z.string() })) - .use(canGetting, input => input.id) + .use(canGetting, (input) => input.id) .errors({ SOME_TYPE_SAFE_ERROR: { - data: z.object({ something: z.string() }) - } + data: z.object({ something: z.string() }), + }, }) .handler(async ({ input, errors, context }) => { // do something @@ -138,6 +138,6 @@ If you're interesting in sponsoring oRPC, you can do it [here](https://github.co

- Sponsors + Sponsors

diff --git a/apps/content/blog/you-might-not-need-a-form-library.mdx b/apps/content/blog/you-might-not-need-a-form-library.mdx index ca8a67f747..27b3cd771c 100644 --- a/apps/content/blog/you-might-not-need-a-form-library.mdx +++ b/apps/content/blog/you-might-not-need-a-form-library.mdx @@ -1,16 +1,16 @@ --- -title: "You Might Not Need a Form Library" -description: "Your API schema already knows what valid input looks like. Reuse it in the browser to parse plain HTML forms and show field errors, without a form library." +title: 'You Might Not Need a Form Library' +description: 'Your API schema already knows what valid input looks like. Reuse it in the browser to parse plain HTML forms and show field errors, without a form library.' type: blog date: 2026-08-22 search: tags: - Engineering authors: - name: Định Lê - role: Creator of oRPC - url: https://github.com/dinwwwh - avatar: https://github.com/dinwwwh.png + name: Định Lê + role: Creator of oRPC + url: https://github.com/dinwwwh + avatar: https://github.com/dinwwwh.png ai: exclude: true --- @@ -60,9 +60,7 @@ The [Request Validation Plugin](/docs/plugins/request-validation) runs your cont import { RequestValidationLinkPlugin } from '@orpc/contract/plugins' const link = new RPCLink({ - plugins: [ - new RequestValidationLinkPlugin(contract), - ], + plugins: [new RequestValidationLinkPlugin(contract)], }) ``` @@ -82,8 +80,7 @@ export function ContactForm() { try { const output = await client.contact.send(parseFormData(form)) console.log(output) - } - catch (error) { + } catch (error) { setError(error) } } diff --git a/apps/content/blume.config.ts b/apps/content/blume.config.ts index cb08f34681..ab0d32ba6e 100644 --- a/apps/content/blume.config.ts +++ b/apps/content/blume.config.ts @@ -1,11 +1,13 @@ import { fileURLToPath } from 'node:url' + import { defineConfig } from 'blume' import { posthog } from 'blume/analytics' import { cloudflare } from 'blume/deploy' import { filesystem } from 'blume/sources' -import { serverSourcemapsPlugin } from './dev/server-sourcemaps' -import { searchCodeIndexPlugin } from './search/code-index' -import { sponsorAdsInjectPlugin } from './sponsors/inject' + +import { serverSourcemapsPlugin } from './dev/server-sourcemaps.ts' +import { searchCodeIndexPlugin } from './search/code-index.ts' +import { sponsorAdsInjectPlugin } from './sponsors/inject.ts' export default defineConfig({ title: 'oRPC', @@ -20,10 +22,7 @@ export default defineConfig({ sources: [ filesystem({ root: '.', - include: [ - 'docs/**/*.{md,mdx}', - 'blog/**/*.{md,mdx}', - ], + include: ['docs/**/*.{md,mdx}', 'blog/**/*.{md,mdx}'], }), ], }, @@ -89,10 +88,7 @@ export default defineConfig({ organization: { name: 'middleapi', url: 'https://github.com/middleapi', - sameAs: [ - 'https://x.com/middleapi', - 'https://opencollective.com/middleapi', - ], + sameAs: ['https://x.com/middleapi', 'https://opencollective.com/middleapi'], }, /** The homepage's SoftwareApplication JSON-LD node, which tells search engines and agents what oRPC is. */ software: { @@ -122,11 +118,17 @@ export default defineConfig({ { from: '/docs/advanced/scaling-large-projects', to: '/docs/contract/client-factory' }, { from: '/docs/openapi/openapi-to-contract', to: '/docs/contract/generate-from-openapi' }, - { from: '/docs/advanced/exceeds-the-maximum-length-problem', to: '/docs/recipes/exceeds-the-maximum-length-problem' }, + { + from: '/docs/advanced/exceeds-the-maximum-length-problem', + to: '/docs/recipes/exceeds-the-maximum-length-problem', + }, { from: '/docs/advanced/publish-client-to-npm', to: '/docs/recipes/publish-client-to-npm' }, { from: '/docs/advanced/testing-mocking', to: '/docs/recipes/testing-and-mocking' }, { from: '/docs/advanced/validation-errors', to: '/docs/recipes/validation-customization' }, - { from: '/docs/openapi/advanced/disabling-output-validation', to: '/docs/recipes/validation-customization' }, + { + from: '/docs/openapi/advanced/disabling-output-validation', + to: '/docs/recipes/validation-customization', + }, { from: '/docs/best-practices/dedupe-middleware', to: '/docs/recipes/dedupe-middleware' }, { from: '/docs/best-practices/monorepo-setup', to: '/docs/recipes/monorepo-setup' }, { from: '/docs/best-practices/no-throw-literal', to: '/docs/recipes/no-throw-literal' }, @@ -145,8 +147,14 @@ export default defineConfig({ { from: '/docs/openapi/openapi-specification', to: '/docs/openapi/specification' }, { from: '/docs/openapi/advanced/openapi-json-serializer', to: '/docs/openapi/serializer' }, { from: '/docs/openapi/input-output-structure', to: '/docs/openapi/input-and-output-mapping' }, - { from: '/docs/openapi/advanced/redirect-response', to: '/docs/openapi/input-and-output-mapping#redirect-response' }, - { from: '/docs/openapi/advanced/expanding-type-support-for-openapi-link', to: '/docs/openapi/expanding-type-support-for-link' }, + { + from: '/docs/openapi/advanced/redirect-response', + to: '/docs/openapi/input-and-output-mapping#redirect-response', + }, + { + from: '/docs/openapi/advanced/expanding-type-support-for-openapi-link', + to: '/docs/openapi/expanding-type-support-for-link', + }, { from: '/docs/event-iterator', to: '/docs/async-iterator-object' }, { from: '/docs/client/event-iterator', to: '/docs/client/async-iterator-object' }, @@ -165,10 +173,16 @@ export default defineConfig({ { from: '/docs/plugins/hibernation', to: '/docs/integrations/hibernation' }, { from: '/docs/integrations/react-swr', to: '/docs/integrations/swr' }, - { from: '/docs/openapi/integrations/implement-contract-in-nest', to: '/docs/integrations/nest' }, + { + from: '/docs/openapi/integrations/implement-contract-in-nest', + to: '/docs/integrations/nest', + }, { from: '/docs/openapi/integrations/trpc', to: '/docs/integrations/trpc' }, { from: '/docs/server-action', to: '/docs/integrations/next' }, - { from: '/docs/integrations/tanstack-query-old/:slug*', to: '/docs/integrations/tanstack-query' }, + { + from: '/docs/integrations/tanstack-query-old/:slug*', + to: '/docs/integrations/tanstack-query', + }, ], export: true, integrations: [ @@ -190,7 +204,9 @@ export default defineConfig({ name: 'header-override', hooks: { 'astro:config:setup': ({ updateConfig }) => { - const headerPath = fileURLToPath(new URL('./components/blume/Header.astro', import.meta.url)) + const headerPath = fileURLToPath( + new URL('./components/blume/Header.astro', import.meta.url), + ) updateConfig({ vite: { resolve: { @@ -207,8 +223,13 @@ export default defineConfig({ name: 'twoslash-popups', hooks: { 'astro:config:setup': ({ injectScript }) => { - const clientPath = fileURLToPath(new URL('./components/blume/twoslash-popups.ts', import.meta.url)) - injectScript('page', `import '${clientPath.replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}'`) + const clientPath = fileURLToPath( + new URL('./components/blume/twoslash-popups.ts', import.meta.url), + ) + injectScript( + 'page', + `import '${clientPath.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`, + ) }, }, }, @@ -221,7 +242,10 @@ export default defineConfig({ updateConfig({ vite: { plugins: [searchCodeIndexPlugin()] } }) // Render the matched passage in the preview pane the way the page does. const clientPath = fileURLToPath(new URL('./search/page-preview.ts', import.meta.url)) - injectScript('page', `import '${clientPath.replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}'`) + injectScript( + 'page', + `import '${clientPath.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`, + ) }, }, }, @@ -234,7 +258,10 @@ export default defineConfig({ // later two-cell slots with a random pick per view. updateConfig({ vite: { plugins: [sponsorAdsInjectPlugin()] } }) const clientPath = fileURLToPath(new URL('./sponsors/client.ts', import.meta.url)) - injectScript('page', `import '${clientPath.replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}'`) + injectScript( + 'page', + `import '${clientPath.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`, + ) }, }, }, diff --git a/apps/content/components/blume/NavTree.astro b/apps/content/components/blume/NavTree.astro index 51134ec72f..a5e2c2f3d5 100644 --- a/apps/content/components/blume/NavTree.astro +++ b/apps/content/components/blume/NavTree.astro @@ -4,6 +4,7 @@ import type { UIStrings } from "blume/core/i18n-ui.ts"; import type { NavNode } from "blume/core/types.ts"; import Icon from "blume/components/Icon.astro"; import { withBase } from "blume/components/islands/base-path.ts"; +// oxlint-disable-next-line import/no-self-import import Self from "./NavTree.astro"; interface PagePanel { diff --git a/apps/content/components/blume/twoslash-popups.ts b/apps/content/components/blume/twoslash-popups.ts index d61dd2518f..3d9ba023fc 100644 --- a/apps/content/components/blume/twoslash-popups.ts +++ b/apps/content/components/blume/twoslash-popups.ts @@ -43,7 +43,7 @@ const OPEN_CLASS = 'twoslash-open' const BRIDGE_CLASS = 'twoslash-bridge' const DISMISSED_CLASS = 'twoslash-dismissed' -function popupFor(target: EventTarget | null): { hover: Element, popup: HTMLElement } | null { +function popupFor(target: EventTarget | null): { hover: Element; popup: HTMLElement } | null { const element = target instanceof Element ? target : null const hover = element?.closest('.twoslash-hover') const popup = hover?.querySelector('.twoslash-popup-container') @@ -62,7 +62,7 @@ function bridgeFor(hover: Element): HTMLElement { return bridge } -let placed: { hover: Element, popup: HTMLElement } | null = null +let placed: { hover: Element; popup: HTMLElement } | null = null function place(hover: Element, popup: HTMLElement): void { // Un-dismiss first: the dismissal rule display:nones the popup, which @@ -137,8 +137,7 @@ document.addEventListener('click', (event) => { const found = popupFor(event.target) if (!found) { setOpen(null) - } - else if (lastPointerType !== 'mouse') { + } else if (lastPointerType !== 'mouse') { setOpen(found.hover) reveal(event.target) } @@ -150,18 +149,22 @@ document.addEventListener('click', (event) => { // bridge even where a sticky tap-:hover would keep the CSS reveal alive; // the next place() lifts it. Scrolling inside the popup itself is the one // scroller reading depends on, so it never dismisses. -document.addEventListener('scroll', (event) => { - if (!placed) { - return - } - if (event.target instanceof Node && placed.popup.contains(event.target)) { - return - } - if (lastPointerType === 'mouse') { - place(placed.hover, placed.popup) - return - } - setOpen(null) - placed.hover.classList.add(DISMISSED_CLASS) - placed = null -}, { capture: true, passive: true }) +document.addEventListener( + 'scroll', + (event) => { + if (!placed) { + return + } + if (event.target instanceof Node && placed.popup.contains(event.target)) { + return + } + if (lastPointerType === 'mouse') { + place(placed.hover, placed.popup) + return + } + setOpen(null) + placed.hover.classList.add(DISMISSED_CLASS) + placed = null + }, + { capture: true, passive: true }, +) diff --git a/apps/content/dev/server-sourcemaps.ts b/apps/content/dev/server-sourcemaps.ts index 02aa6a1170..2317409f38 100644 --- a/apps/content/dev/server-sourcemaps.ts +++ b/apps/content/dev/server-sourcemaps.ts @@ -30,8 +30,11 @@ export function serverSourcemapsPlugin(): Plugin { // Runners reach this method through the environment's invoke handler. It // forwards its options to Vite's `fetchModule`, which reads // `inlineSourceMap` although the method's own type leaves it out. - const fetchModule: (id: string, importer: string | undefined, options: FetchModuleOptions) => ReturnType - = environment.fetchModule.bind(environment) + const fetchModule: ( + id: string, + importer: string | undefined, + options: FetchModuleOptions, + ) => ReturnType = environment.fetchModule.bind(environment) environment.fetchModule = (id, importer, options) => fetchModule(id, importer, { ...options, inlineSourceMap: false }) } diff --git a/apps/content/docs/adapters/astro.mdx b/apps/content/docs/adapters/astro.mdx index 3f27c4a783..da7bb0cccb 100644 --- a/apps/content/docs/adapters/astro.mdx +++ b/apps/content/docs/adapters/astro.mdx @@ -1,8 +1,8 @@ --- -title: "Astro Adapter" -description: "Use oRPC inside an Astro project by mounting a handler in an on-demand API route through the Fetch API Adapter, and call procedures directly during SSR." +title: 'Astro Adapter' +description: 'Use oRPC inside an Astro project by mounting a handler in an on-demand API route through the Fetch API Adapter, and call procedures directly during SSR.' sidebar: - label: "Astro" + label: 'Astro' --- [Astro](https://astro.build/) is a JavaScript web framework optimized for building fast, content-driven websites. Its endpoints follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -27,7 +27,7 @@ export const prerender = false export const ALL: APIRoute = async ({ request }) => { const { response } = await handler.handle(request, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) return response ?? new Response('Not found', { status: 404 }) @@ -86,7 +86,7 @@ export const client: RouterClient = globalThis.$client ?? createO import { createRouterClient } from '@orpc/server' globalThis.$client = createRouterClient(router, { - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) ``` diff --git a/apps/content/docs/adapters/aws-lambda.mdx b/apps/content/docs/adapters/aws-lambda.mdx index 0ff3ffcc45..b254059ba8 100644 --- a/apps/content/docs/adapters/aws-lambda.mdx +++ b/apps/content/docs/adapters/aws-lambda.mdx @@ -1,8 +1,8 @@ --- -title: "AWS Lambda Adapter" -description: "Use oRPC on AWS Lambda behind API Gateway (payload formats 1.0 and 2.0) and Lambda Function URLs using response streaming." +title: 'AWS Lambda Adapter' +description: 'Use oRPC on AWS Lambda behind API Gateway (payload formats 1.0 and 2.0) and Lambda Function URLs using response streaming.' sidebar: - label: "AWS Lambda" + label: 'AWS Lambda' --- :::warning @@ -22,9 +22,7 @@ import { CORSHandlerPlugin } from '@orpc/server/plugins' declare const awslambda: AwsLambdaGlobal const handler = new RPCHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -32,22 +30,24 @@ const handler = new RPCHandler(router, { ], }) -export const rpc = awslambda.streamifyResponse(async (event, responseStream, context) => { - const { matched } = await handler.handle(event, responseStream, { - prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function - }) - - if (matched) { - return - } - - awslambda.HttpResponseStream.from(responseStream, { - statusCode: 404, - headers: {}, - cookies: [], - }).end('Not found') -}) +export const rpc = awslambda.streamifyResponse( + async (event, responseStream, context) => { + const { matched } = await handler.handle(event, responseStream, { + prefix: '/rpc', + context: {}, // Provide initial context if needed, can be an async function + }) + + if (matched) { + return + } + + awslambda.HttpResponseStream.from(responseStream, { + statusCode: 404, + headers: {}, + cookies: [], + }).end('Not found') + }, +) ``` ```ts OpenAPI @@ -59,9 +59,7 @@ import { CORSHandlerPlugin } from '@orpc/server/plugins' declare const awslambda: AwsLambdaGlobal const handler = new OpenAPIHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -69,22 +67,24 @@ const handler = new OpenAPIHandler(router, { ], }) -export const api = awslambda.streamifyResponse(async (event, responseStream, context) => { - const { matched } = await handler.handle(event, responseStream, { - prefix: '/api', - context: {} // Provide initial context if needed, can be an async function - }) - - if (matched) { - return - } - - awslambda.HttpResponseStream.from(responseStream, { - statusCode: 404, - headers: {}, - cookies: [], - }).end('Not found') -}) +export const api = awslambda.streamifyResponse( + async (event, responseStream, context) => { + const { matched } = await handler.handle(event, responseStream, { + prefix: '/api', + context: {}, // Provide initial context if needed, can be an async function + }) + + if (matched) { + return + } + + awslambda.HttpResponseStream.from(responseStream, { + statusCode: 404, + headers: {}, + cookies: [], + }).end('Not found') + }, +) ``` diff --git a/apps/content/docs/adapters/browser.mdx b/apps/content/docs/adapters/browser.mdx index 5136a99b3f..c624d2a8fe 100644 --- a/apps/content/docs/adapters/browser.mdx +++ b/apps/content/docs/adapters/browser.mdx @@ -1,8 +1,8 @@ --- -title: "Browser Adapter" +title: 'Browser Adapter' description: "Use oRPC for typesafe messaging between extension scripts or browser windows via the Message Port Adapter, and relay scripts that can't connect directly." sidebar: - label: "Browser" + label: 'Browser' --- Enable typesafe communication between browser scripts using the [Message Port Adapter](/docs/adapters/message-port). @@ -36,7 +36,7 @@ browser.runtime.onConnect.addListener((port) => { * that receives the per-call request as its first argument, and is **not** * related to the initial upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) }) ``` @@ -83,7 +83,7 @@ window.addEventListener('message', (event) => { * that receives the per-call request as its first argument, and is **not** * related to the initial upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) event.data.start() @@ -164,7 +164,7 @@ browser.runtime.onConnect.addListener((port) => { * that receives the per-call request as its first argument, and is **not** * related to the initial upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) }) ``` diff --git a/apps/content/docs/adapters/cloudflare-workers.mdx b/apps/content/docs/adapters/cloudflare-workers.mdx index ee455f4456..791a1c3259 100644 --- a/apps/content/docs/adapters/cloudflare-workers.mdx +++ b/apps/content/docs/adapters/cloudflare-workers.mdx @@ -1,8 +1,8 @@ --- -title: "Cloudflare Workers Adapter" -description: "Use oRPC on Cloudflare Workers through the Fetch API Adapter, with the compatibility flags that make request cancellation and unhandled rejections behave." +title: 'Cloudflare Workers Adapter' +description: 'Use oRPC on Cloudflare Workers through the Fetch API Adapter, with the compatibility flags that make request cancellation and unhandled rejections behave.' sidebar: - label: "Cloudflare Workers" + label: 'Cloudflare Workers' --- [Cloudflare Workers](https://developers.cloudflare.com/workers/) follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -17,9 +17,7 @@ import { RPCHandler } from '@orpc/server/fetch' import { CORSHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -31,7 +29,7 @@ export default { async fetch(request, env, ctx) { const { matched, response } = await handler.handle(request, { prefix: '/rpc', - context: { env, ctx } // Provide initial context if needed, can be an async function + context: { env, ctx }, // Provide initial context if needed, can be an async function }) if (matched) { @@ -49,9 +47,7 @@ import { onError } from '@orpc/server' import { CORSHandlerPlugin } from '@orpc/server/plugins' const handler = new OpenAPIHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -63,7 +59,7 @@ export default { async fetch(request, env, ctx) { const { matched, response } = await handler.handle(request, { prefix: '/api', - context: { env, ctx } // Provide initial context if needed, can be an async function + context: { env, ctx }, // Provide initial context if needed, can be an async function }) if (matched) { @@ -83,7 +79,7 @@ Use a `compatibility_date` of `2026-11-17` or later: ```jsonc title="wrangler.jsonc" { - "compatibility_date": "2026-11-17" + "compatibility_date": "2026-11-17", } ``` diff --git a/apps/content/docs/adapters/electron.mdx b/apps/content/docs/adapters/electron.mdx index f8ff4f9ef6..add635db55 100644 --- a/apps/content/docs/adapters/electron.mdx +++ b/apps/content/docs/adapters/electron.mdx @@ -1,8 +1,8 @@ --- -title: "Electron Adapter" +title: 'Electron Adapter' description: "Use oRPC for typesafe communication between Electron's main, preload, and renderer processes by passing a MessagePort through the Message Port Adapter." sidebar: - label: "Electron" + label: 'Electron' --- Establish typesafe 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. @@ -34,7 +34,7 @@ app.whenReady().then(() => { * that receives the per-call request as its first argument, and is **not** * related to the initial upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) serverPort.start() diff --git a/apps/content/docs/adapters/elysia.mdx b/apps/content/docs/adapters/elysia.mdx index b3e0d482af..ffce3ff933 100644 --- a/apps/content/docs/adapters/elysia.mdx +++ b/apps/content/docs/adapters/elysia.mdx @@ -1,8 +1,8 @@ --- -title: "Elysia Adapter" +title: 'Elysia Adapter' description: "Use oRPC inside an Elysia project on Bun by mounting a handler on a wildcard route through the Fetch API Adapter, with Elysia's body parsing turned off." sidebar: - label: "Elysia" + label: 'Elysia' --- [Elysia](https://elysiajs.com/) is a high-performance web framework for [Bun](https://bun.com/) that adheres to the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -23,16 +23,20 @@ const handler = new RPCHandler(router, { }) const app = new Elysia() - .all('/rpc*', async ({ request }: { request: Request }) => { - const { response } = await handler.handle(request, { - prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function - }) - - return response ?? new Response('Not found', { status: 404 }) - }, { - parse: 'none' // Skip Elysia's body parsing; oRPC reads the raw request itself - }) + .all( + '/rpc*', + async ({ request }: { request: Request }) => { + const { response } = await handler.handle(request, { + prefix: '/rpc', + context: {}, // Provide initial context if needed, can be an async function + }) + + return response ?? new Response('Not found', { status: 404 }) + }, + { + parse: 'none', // Skip Elysia's body parsing; oRPC reads the raw request itself + }, + ) .listen(3000) console.log('Elysia is running at http://localhost:3000') diff --git a/apps/content/docs/adapters/expo.mdx b/apps/content/docs/adapters/expo.mdx index 9543c68104..b0c5c949f9 100644 --- a/apps/content/docs/adapters/expo.mdx +++ b/apps/content/docs/adapters/expo.mdx @@ -1,8 +1,8 @@ --- -title: "Expo Adapter" -description: "Use oRPC in an Expo app over fetch or WebSocket, including streaming support, binary data, and the SDK versions each feature needs." +title: 'Expo Adapter' +description: 'Use oRPC in an Expo app over fetch or WebSocket, including streaming support, binary data, and the SDK versions each feature needs.' sidebar: - label: "Expo" + label: 'Expo' --- [Expo](https://expo.dev/) is the supported way to run oRPC in a React Native app. It installs the browser APIs oRPC depends on, which bare React Native does not provide, so JSON calls, [AsyncIteratorObject](/docs/async-iterator-object), and streamed downloads work in a plain Expo project with no polyfill and no Metro configuration. [Some `File` and `Blob` cases](#feature-support) need one extra package. @@ -84,8 +84,8 @@ oRPC treats binary data two different ways, and the difference decides what work Expo never parses multipart, and the WebSocket link cannot even produce it, which is why every nested row below fails somewhere. -| Feature | Fetch | WebSocket | -| ----------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | +| Feature | Fetch | WebSocket | +| ----------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------- | | [AsyncIteratorObject](/docs/async-iterator-object) download | ✅ | ✅ | | `AsyncIteratorObject` upload | 🟡 buffered | ✅ | | [`ReadableStream`](/docs/binary-data#readablestreamuint8array) download | ✅ | ✅ | @@ -118,7 +118,11 @@ class File extends Blob { lastModified: number webkitRelativePath = '' - constructor(fileBits: BlobPart[] | Iterable, fileName: string, options?: FilePropertyBag) { + constructor( + fileBits: BlobPart[] | Iterable, + fileName: string, + options?: FilePropertyBag, + ) { super(fileBits, options) this.name = String(fileName) @@ -145,10 +149,10 @@ A nested `File` or `Blob` is packed into a `FormData` and sent as `multipart/for Writing multipart works, but only on the fetch link, where `expo/fetch` does the encoding. The WebSocket link uses `Response` to write it too, so that link fails in both directions. -| Link | Nested upload | Nested download | -| --------- | ------------------------------------------ | --------------- | -| Fetch | 🟡 with the [Blob polyfill](#blob-polyfill) | ❌ | -| WebSocket | ❌ | ❌ | +| Link | Nested upload | Nested download | +| --------- | ------------------------------------------- | --------------- | +| Fetch | 🟡 with the [Blob polyfill](#blob-polyfill) | ❌ | +| WebSocket | ❌ | ❌ | **The easy fix is to avoid multipart.** Keep files at the root of a procedure's input or output instead of inside an object. Or extend the [RPC JSON Serializer](/docs/rpc/serializer) to carry binary as `base64`. diff --git a/apps/content/docs/adapters/express.mdx b/apps/content/docs/adapters/express.mdx index 572957b587..f10919190b 100644 --- a/apps/content/docs/adapters/express.mdx +++ b/apps/content/docs/adapters/express.mdx @@ -1,8 +1,8 @@ --- -title: "Express.js Adapter" -description: "Use oRPC inside an Express.js project by mounting a handler as a middleware through the Node HTTP Adapter, without conflicts from body-parsing middleware." +title: 'Express.js Adapter' +description: 'Use oRPC inside an Express.js project by mounting a handler as a middleware through the Node HTTP Adapter, without conflicts from body-parsing middleware.' sidebar: - label: "Express.js" + label: 'Express.js' --- [Express.js](https://expressjs.com/) is a popular Node.js framework for building web applications. oRPC integrates with it through the [Node HTTP Adapter](/docs/adapters/node-http). @@ -34,7 +34,7 @@ const handler = new RPCHandler(router, { app.use('/rpc{/*path}', async (req, res, next) => { const { matched } = await handler.handle(req, res, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { diff --git a/apps/content/docs/adapters/fastify.mdx b/apps/content/docs/adapters/fastify.mdx index 7c8981800e..ab1ad309e5 100644 --- a/apps/content/docs/adapters/fastify.mdx +++ b/apps/content/docs/adapters/fastify.mdx @@ -1,8 +1,8 @@ --- -title: "Fastify Adapter" -description: "Use oRPC with Fastify servers via RPCHandler or OpenAPIHandler, with content type parser tips and event stream options." +title: 'Fastify Adapter' +description: 'Use oRPC with Fastify servers via RPCHandler or OpenAPIHandler, with content type parser tips and event stream options.' sidebar: - label: "Fastify" + label: 'Fastify' --- ## Server Usage @@ -16,9 +16,7 @@ import { CORSHandlerPlugin } from '@orpc/server/plugins' import Fastify from 'fastify' const handler = new RPCHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -31,7 +29,7 @@ const app = Fastify() app.all('/rpc/*', async (req, reply) => { const { matched } = await handler.handle(req, reply, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { @@ -51,9 +49,7 @@ import { CORSHandlerPlugin } from '@orpc/server/plugins' import Fastify from 'fastify' const handler = new OpenAPIHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -66,7 +62,7 @@ const app = Fastify() app.all('/api/*', async (req, reply) => { const { matched } = await handler.handle(req, reply, { prefix: '/api', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { diff --git a/apps/content/docs/adapters/fetch-api.mdx b/apps/content/docs/adapters/fetch-api.mdx index 1c9b92b191..b01f874c0c 100644 --- a/apps/content/docs/adapters/fetch-api.mdx +++ b/apps/content/docs/adapters/fetch-api.mdx @@ -1,8 +1,8 @@ --- -title: "Fetch API Adapter" -description: "Use oRPC with the Fetch API on both servers and clients, in runtimes such as Bun and Deno, and configure how event streams are sent to the client." +title: 'Fetch API Adapter' +description: 'Use oRPC with the Fetch API on both servers and clients, in runtimes such as Bun and Deno, and configure how event streams are sent to the client.' sidebar: - label: "Fetch API" + label: 'Fetch API' --- ## Server Usage @@ -15,9 +15,7 @@ import { CORSHandlerPlugin } from '@orpc/server/plugins' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -28,7 +26,7 @@ const handler = new RPCHandler(router, { export async function fetch(request: Request): Promise { const { matched, response } = await handler.handle(request, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { @@ -45,9 +43,7 @@ import { CORSHandlerPlugin } from '@orpc/server/plugins' import { onError } from '@orpc/server' const handler = new OpenAPIHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -58,7 +54,7 @@ const handler = new OpenAPIHandler(router, { export async function fetch(request: Request): Promise { const { matched, response } = await handler.handle(request, { prefix: '/api', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { @@ -118,7 +114,8 @@ const link = new RPCLink({ console.error(error) }), ], - fetch: (request, init) => { // <- override fetch if needed + fetch: (request, init) => { + // <- override fetch if needed return globalThis.fetch(request, { ...init, credentials: 'include', // Include cookies on cross-origin requests @@ -140,7 +137,8 @@ const link = new OpenAPILink(contract, { console.error(error) }), ], - fetch: (request, init) => { // <- override fetch if needed + fetch: (request, init) => { + // <- override fetch if needed return globalThis.fetch(request, { ...init, credentials: 'include', // Include cookies on cross-origin requests diff --git a/apps/content/docs/adapters/h3.mdx b/apps/content/docs/adapters/h3.mdx index efc6a92c7d..f1900598c5 100644 --- a/apps/content/docs/adapters/h3.mdx +++ b/apps/content/docs/adapters/h3.mdx @@ -1,8 +1,8 @@ --- -title: "H3 Adapter" -description: "Use oRPC inside an H3 project by mounting a handler on a wildcard route. H3 is built on web standards, so oRPC integrates through the Fetch API Adapter." +title: 'H3 Adapter' +description: 'Use oRPC inside an H3 project by mounting a handler on a wildcard route. H3 is built on web standards, so oRPC integrates through the Fetch API Adapter.' sidebar: - label: "H3" + label: 'H3' --- [H3](https://h3.dev/) is a universal, tiny, and fast web framework built on top of web standards, so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -27,7 +27,7 @@ const handler = new RPCHandler(router, { app.use('/rpc/**', async (event) => { const { matched, response } = await handler.handle(event.req, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { diff --git a/apps/content/docs/adapters/hono.mdx b/apps/content/docs/adapters/hono.mdx index e163eaf14e..1d568e648f 100644 --- a/apps/content/docs/adapters/hono.mdx +++ b/apps/content/docs/adapters/hono.mdx @@ -1,8 +1,8 @@ --- -title: "Hono Adapter" -description: "Use oRPC inside a Hono project by mounting a handler as a middleware through the Fetch API Adapter, with a fix for middleware that reads the body first." +title: 'Hono Adapter' +description: 'Use oRPC inside a Hono project by mounting a handler as a middleware through the Fetch API Adapter, with a fix for middleware that reads the body first.' sidebar: - label: "Hono" + label: 'Hono' --- [Hono](https://hono.dev/) is a high-performance web framework built on top of the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -27,7 +27,7 @@ const handler = new RPCHandler(router, { app.use('/rpc/*', async (c, next) => { const { matched, response } = await handler.handle(c.req.raw, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { @@ -59,12 +59,12 @@ app.use('/rpc/*', async (c, next) => { 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, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { diff --git a/apps/content/docs/adapters/index.mdx b/apps/content/docs/adapters/index.mdx index 1f567ca0a5..5d9c33e223 100644 --- a/apps/content/docs/adapters/index.mdx +++ b/apps/content/docs/adapters/index.mdx @@ -1,8 +1,8 @@ --- -title: "Adapters" -description: "Pick the oRPC adapter for your runtime, framework, or messaging channel, from the Fetch API and Node HTTP to Next.js, Hono, WebSocket, and Electron." +title: 'Adapters' +description: 'Pick the oRPC adapter for your runtime, framework, or messaging channel, from the Fetch API and Node HTTP to Next.js, Hono, WebSocket, and Electron.' seo: - title: "Adapters: Run oRPC on Any Runtime or Framework" + title: 'Adapters: Run oRPC on Any Runtime or Framework' sidebar: hidden: true --- diff --git a/apps/content/docs/adapters/message-port.mdx b/apps/content/docs/adapters/message-port.mdx index cbd2881093..1d2142e07d 100644 --- a/apps/content/docs/adapters/message-port.mdx +++ b/apps/content/docs/adapters/message-port.mdx @@ -1,8 +1,8 @@ --- -title: "Message Port Adapter" -description: "Use oRPC over the Message Port API to communicate between contexts such as iframes, web workers, and service workers." +title: 'Message Port Adapter' +description: 'Use oRPC over the Message Port API to communicate between contexts such as iframes, web workers, and service workers.' sidebar: - label: "Message Port" + label: 'Message Port' --- ## Basic Usage @@ -33,7 +33,7 @@ handler.upgrade(serverPort, { * that receives the per-call request as its first argument, and is **not** * related to the initial upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) serverPort.start() @@ -54,7 +54,7 @@ const link = new RPCLink({ * Optional headers to attach to each per-call request. * These can be accessed in the server context or via the Request Headers Plugin. */ - headers: () => ({}) + headers: () => ({}), }) clientPort.start() @@ -75,7 +75,7 @@ const handler = new RPCHandler(router, { experimental_transfer: (message, port) => { const transfer = deepFindTransferableObjects(message) // implement your own logic return transfer.length ? transfer : null // only enable when needed - } + }, }) ``` @@ -84,7 +84,7 @@ const link = new RPCLink({ experimental_transfer: (message) => { const transfer = deepFindTransferableObjects(message) // implement your own logic return transfer.length ? transfer : null // only enable when needed - } + }, }) ``` diff --git a/apps/content/docs/adapters/next.mdx b/apps/content/docs/adapters/next.mdx index c960126bc6..2158038250 100644 --- a/apps/content/docs/adapters/next.mdx +++ b/apps/content/docs/adapters/next.mdx @@ -1,8 +1,8 @@ --- -title: "Next.js Adapter" -description: "Use oRPC inside a Next.js project by mounting a handler in an App Router route handler or Pages Router API route, with a client that also works during SSR." +title: 'Next.js Adapter' +description: 'Use oRPC inside a Next.js project by mounting a handler in an App Router route handler or Pages Router API route, with a client that also works during SSR.' sidebar: - label: "Next.js" + label: 'Next.js' --- [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) through the [Fetch API Adapter](/docs/adapters/fetch-api) and [Node HTTP Adapter](/docs/adapters/node-http) respectively. @@ -30,7 +30,7 @@ const handler = new RPCHandler(router, { async function handleRequest(request: Request) { const { response } = await handler.handle(request, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) return response ?? new Response('Not found', { status: 404 }) @@ -72,7 +72,7 @@ export const config = { export default async (req: NextApiRequest, res: NextApiResponse) => { const { matched } = await handler.handle(req, res, { prefix: '/api/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { diff --git a/apps/content/docs/adapters/node-http.mdx b/apps/content/docs/adapters/node-http.mdx index 02bca577b3..4a5dad4000 100644 --- a/apps/content/docs/adapters/node-http.mdx +++ b/apps/content/docs/adapters/node-http.mdx @@ -1,8 +1,8 @@ --- -title: "Node HTTP Adapter" -description: "Use oRPC with Node HTTP, HTTPS, and HTTP2 servers via RPCHandler or OpenAPIHandler, with CORS and event stream options." +title: 'Node HTTP Adapter' +description: 'Use oRPC with Node HTTP, HTTPS, and HTTP2 servers via RPCHandler or OpenAPIHandler, with CORS and event stream options.' sidebar: - label: "Node HTTP" + label: 'Node HTTP' --- ## Server Usage @@ -16,9 +16,7 @@ import { CORSHandlerPlugin } from '@orpc/server/plugins' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -29,7 +27,7 @@ const handler = new RPCHandler(router, { const server = createServer(async (req, res) => { const { matched } = await handler.handle(req, res, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { @@ -50,9 +48,7 @@ import { CORSHandlerPlugin } from '@orpc/server/plugins' import { onError } from '@orpc/server' const handler = new OpenAPIHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], interceptors: [ onError((error) => { console.error(error) @@ -63,7 +59,7 @@ const handler = new OpenAPIHandler(router, { const server = createServer(async (req, res) => { const { matched } = await handler.handle(req, res, { prefix: '/api', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (matched) { diff --git a/apps/content/docs/adapters/nuxt.mdx b/apps/content/docs/adapters/nuxt.mdx index 35ec93af1d..d50cc7df9e 100644 --- a/apps/content/docs/adapters/nuxt.mdx +++ b/apps/content/docs/adapters/nuxt.mdx @@ -1,8 +1,8 @@ --- -title: "Nuxt Adapter" -description: "Use oRPC inside a Nuxt project by mounting a handler in a server route and setting up an SSR-compatible client in a Nuxt plugin." +title: 'Nuxt Adapter' +description: 'Use oRPC inside a Nuxt project by mounting a handler in a server route and setting up an SSR-compatible client in a Nuxt plugin.' sidebar: - label: "Nuxt" + label: 'Nuxt' --- [Nuxt](https://nuxt.com/) is a popular Vue.js framework for building server-side applications. Its server engine follows web standards, so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => { const { response } = await handler.handle(request, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) if (response) { diff --git a/apps/content/docs/adapters/react-router.mdx b/apps/content/docs/adapters/react-router.mdx index 8f99d43c90..d6f6f84217 100644 --- a/apps/content/docs/adapters/react-router.mdx +++ b/apps/content/docs/adapters/react-router.mdx @@ -1,8 +1,8 @@ --- -title: "React Router Adapter" +title: 'React Router Adapter' description: "Use oRPC inside a React Router or Remix project by mounting a handler in a resource route's loader and action through the Fetch API Adapter." sidebar: - label: "React Router" + label: 'React Router' --- [React Router](https://reactrouter.com/) is a multi-strategy router for React, and the successor to [Remix](https://remix.run/). In framework mode, its resource routes follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -27,7 +27,7 @@ const handler = new RPCHandler(router, { async function handleRequest(request: Request) { const { response } = await handler.handle(request, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) return response ?? new Response('Not found', { status: 404 }) @@ -46,10 +46,7 @@ export async function action({ request }: ActionFunctionArgs) { import type { RouteConfig } from '@react-router/dev/routes' import { index, route } from '@react-router/dev/routes' -export default [ - index('routes/home.tsx'), - route('rpc/*', 'routes/rpc.ts'), -] satisfies RouteConfig +export default [index('routes/home.tsx'), route('rpc/*', 'routes/rpc.ts')] satisfies RouteConfig ``` diff --git a/apps/content/docs/adapters/solid-start.mdx b/apps/content/docs/adapters/solid-start.mdx index 6ec6bb5241..71a2522c1b 100644 --- a/apps/content/docs/adapters/solid-start.mdx +++ b/apps/content/docs/adapters/solid-start.mdx @@ -1,8 +1,8 @@ --- -title: "SolidStart Adapter" -description: "Use oRPC inside a SolidStart project by mounting a handler in an API route, with a client that forwards request headers during SSR and works in the browser." +title: 'SolidStart Adapter' +description: 'Use oRPC inside a SolidStart project by mounting a handler in an API route, with a client that forwards request headers during SSR and works in the browser.' sidebar: - label: "SolidStart" + label: 'SolidStart' --- [SolidStart](https://start.solidjs.com/) is a full stack JavaScript framework for building web applications with SolidJS. Its API routes follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -27,7 +27,7 @@ const handler = new RPCHandler(router, { async function handle({ request }: APIEvent) { const { response } = await handler.handle(request, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) return response ?? new Response('Not found', { status: 404 }) diff --git a/apps/content/docs/adapters/svelte-kit.mdx b/apps/content/docs/adapters/svelte-kit.mdx index 9a1d805210..e32fb4d835 100644 --- a/apps/content/docs/adapters/svelte-kit.mdx +++ b/apps/content/docs/adapters/svelte-kit.mdx @@ -1,8 +1,8 @@ --- -title: "SvelteKit Adapter" +title: 'SvelteKit Adapter' description: "Use oRPC inside a SvelteKit project by mounting a handler in an endpoint, then call it during SSR through SvelteKit's fetch or a server-side client." sidebar: - label: "SvelteKit" + label: 'SvelteKit' --- [SvelteKit](https://svelte.dev/docs/kit/introduction) is a framework for rapidly developing robust, performant web applications using Svelte. Its endpoints follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -25,7 +25,7 @@ const handler = new RPCHandler(router, { const handle: RequestHandler = async ({ request }) => { const { response } = await handler.handle(request, { prefix: '/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) return response ?? new Response('Not found', { status: 404 }) diff --git a/apps/content/docs/adapters/tanstack-start.mdx b/apps/content/docs/adapters/tanstack-start.mdx index a8184791e7..6e10a322b5 100644 --- a/apps/content/docs/adapters/tanstack-start.mdx +++ b/apps/content/docs/adapters/tanstack-start.mdx @@ -1,8 +1,8 @@ --- -title: "TanStack Start Adapter" -description: "Use oRPC inside a TanStack Start project by mounting a handler in a server route, with an isomorphic client for the browser and SSR via createIsomorphicFn." +title: 'TanStack Start Adapter' +description: 'Use oRPC inside a TanStack Start project by mounting a handler in a server route, with an isomorphic client for the browser and SSR via createIsomorphicFn.' sidebar: - label: "TanStack Start" + label: 'TanStack Start' --- [TanStack Start](https://tanstack.com/start) is a full-stack React framework built on [Vite](https://vite.dev/) and the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). @@ -30,7 +30,7 @@ export const Route = createFileRoute('/api/rpc/$')({ ANY: async ({ request }) => { const { response } = await handler.handle(request, { prefix: '/api/rpc', - context: {} // Provide initial context if needed, can be an async function + context: {}, // Provide initial context if needed, can be an async function }) return response ?? new Response('Not found', { status: 404 }) @@ -54,14 +54,20 @@ import { createIsomorphicFn } from '@tanstack/react-start' import { getRequest, getRequestHeaders } from '@tanstack/react-start/server' const getClientLink = createIsomorphicFn() - .client(() => new RPCLink({ - url: '/api/rpc', - })) - .server(() => new RPCLink({ - url: '/api/rpc', - origin: () => new URL(getRequest().url).origin, // resolve from the incoming request - headers: () => getRequestHeaders(), - })) + .client( + () => + new RPCLink({ + url: '/api/rpc', + }), + ) + .server( + () => + new RPCLink({ + url: '/api/rpc', + origin: () => new URL(getRequest().url).origin, // resolve from the incoming request + headers: () => getRequestHeaders(), + }), + ) ``` :::info @@ -81,18 +87,20 @@ import { createIsomorphicFn } from '@tanstack/react-start' import { getRequestHeaders } from '@tanstack/react-start/server' 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 + .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: '/api/rpc', diff --git a/apps/content/docs/adapters/web-workers.mdx b/apps/content/docs/adapters/web-workers.mdx index 13c7a820c0..2596e82ae1 100644 --- a/apps/content/docs/adapters/web-workers.mdx +++ b/apps/content/docs/adapters/web-workers.mdx @@ -1,8 +1,8 @@ --- -title: "Web Workers Adapter" -description: "Use oRPC for typesafe communication between the main thread and Web Workers via the Message Port Adapter, in browsers and runtimes such as Bun and Deno." +title: 'Web Workers Adapter' +description: 'Use oRPC for typesafe communication between the main thread and Web Workers via the Message Port Adapter, in browsers and runtimes such as Bun and Deno.' sidebar: - label: "Web Workers" + label: 'Web Workers' --- [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. @@ -31,7 +31,7 @@ handler.upgrade(self, { * that receives the per-call request as its first argument, and is **not** * related to the initial upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) ``` @@ -43,7 +43,7 @@ Create a link to communicate with your Web Worker: import { RPCLink } from '@orpc/client/message-port' export const link = new RPCLink({ - port: new Worker('some-worker.js') + port: new Worker('some-worker.js'), }) ``` @@ -56,7 +56,7 @@ import { RPCLink } from '@orpc/client/message-port' import SomeWorker from './some-worker.ts?worker' export const link = new RPCLink({ - port: new SomeWorker() + port: new SomeWorker(), }) ``` diff --git a/apps/content/docs/adapters/websocket.mdx b/apps/content/docs/adapters/websocket.mdx index 4746553a68..68e1dd85cb 100644 --- a/apps/content/docs/adapters/websocket.mdx +++ b/apps/content/docs/adapters/websocket.mdx @@ -1,8 +1,8 @@ --- -title: "WebSocket Adapters" -description: "Use oRPC over WebSocket for low-latency, full-duplex communication, with adapters for ws, crossws, Bun, Deno, Cloudflare, and uWebSockets." +title: 'WebSocket Adapters' +description: 'Use oRPC over WebSocket for low-latency, full-duplex communication, with adapters for ws, crossws, Bun, Deno, Cloudflare, and uWebSockets.' sidebar: - label: "WebSocket" + label: 'WebSocket' --- ## Server Adapters @@ -36,7 +36,7 @@ wss.on('connection', (ws) => { * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) }) ``` @@ -66,7 +66,7 @@ const ws = crossws({ * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) }, close: async (peer) => { @@ -113,13 +113,13 @@ Bun.serve({ * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) }, async close(ws) { await handler.close(ws) }, - } + }, }) ``` @@ -148,7 +148,7 @@ Deno.serve((req) => { * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) return response @@ -186,7 +186,7 @@ export class ChatRoom extends DurableObject { * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) } @@ -218,7 +218,7 @@ const app = App() * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) }, async close(ws, code, message) { @@ -244,7 +244,7 @@ const app = App() import { RPCLink } from '@orpc/client/websocket' const link = new RPCLink({ - connect: info => new WebSocket('ws://localhost:3000'), + 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. @@ -257,7 +257,7 @@ const link = new RPCLink({ * Optional headers to attach to each per-call request. * These can be accessed in the server context or via the Request Headers Plugin. */ - headers: () => ({}) + headers: () => ({}), }) ``` @@ -284,7 +284,7 @@ const link = new RPCLink({ * * @default info => info.attempt === 1 ? 0 : 2_000 */ - delay: info => info.attempt === 1 ? 0 : 2_000, + delay: (info) => (info.attempt === 1 ? 0 : 2_000), /** * Maximum number of consecutive failed attempts before giving up. @@ -320,8 +320,8 @@ const link = new RPCLink({ * * @default 0 */ - delay: 0 - } - } + delay: 0, + }, + }, }) ``` diff --git a/apps/content/docs/adapters/worker-threads.mdx b/apps/content/docs/adapters/worker-threads.mdx index ac69c92107..0e012fa8eb 100644 --- a/apps/content/docs/adapters/worker-threads.mdx +++ b/apps/content/docs/adapters/worker-threads.mdx @@ -1,8 +1,8 @@ --- -title: "Worker Threads Adapter" -description: "Use oRPC for typesafe communication between the Node.js main thread and Worker Threads by passing a MessagePort through the Message Port Adapter." +title: 'Worker Threads Adapter' +description: 'Use oRPC for typesafe communication between the Node.js main thread and Worker Threads by passing a MessagePort through the Message Port Adapter.' sidebar: - label: "Worker Threads" + label: 'Worker Threads' --- Use [Node.js Worker Threads](https://nodejs.org/api/worker_threads.html) with oRPC for typesafe inter-thread communication via the [Message Port Adapter](/docs/adapters/message-port). @@ -32,7 +32,7 @@ parentPort?.on('message', (message) => { * that receives the per-call request as its first argument, and is **not** * related to the initial upgrade request. */ - context: request => ({}), + context: (request) => ({}), }) message.start() @@ -55,7 +55,7 @@ const worker = new Worker(new URL('./some-worker.js', import.meta.url)) worker.postMessage(serverPort, [serverPort]) const link = new RPCLink({ - port: clientPort + port: clientPort, }) clientPort.start() diff --git a/apps/content/docs/api-reference.mdx b/apps/content/docs/api-reference.mdx index a0d1ed2992..4cee28bef6 100644 --- a/apps/content/docs/api-reference.mdx +++ b/apps/content/docs/api-reference.mdx @@ -1,6 +1,6 @@ --- -title: "API Reference" -description: "Full signatures for every function, class, and type the oRPC packages export, generated from the TypeScript declarations each package publishes." +title: 'API Reference' +description: 'Full signatures for every function, class, and type the oRPC packages export, generated from the TypeScript declarations each package publishes.' sidebar: icon: book-marked --- @@ -13,53 +13,53 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki ## Core -| Package | Purpose | Related Guides | -| ------- | ------- | -------------- | +| Package | Purpose | Related Guides | +| -------------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [@orpc/contract](https://npmx.dev/package-docs/@orpc/contract) | Define API contract as the single source of truth. | [Procedure Contract](/docs/contract/procedure), [Contract Router](/docs/contract/router), [Implementation](/docs/contract/implementation), [Generate from OpenAPI](/docs/contract/generate-from-openapi) | -| [@orpc/server](https://npmx.dev/package-docs/@orpc/server) | Build APIs or implement contracts. | [Procedure](/docs/procedure), [Router](/docs/router), [Middleware](/docs/middleware), [Context](/docs/context), [RPC Handler](/docs/rpc/handler) | -| [@orpc/client](https://npmx.dev/package-docs/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](/docs/client/client-side), [Server-Side Clients](/docs/client/server-side), [RPC Link](/docs/rpc/link), [Error Handling](/docs/client/error-handling) | -| [@orpc/openapi](https://npmx.dev/package-docs/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](/docs/openapi/handler), [OpenAPI Link](/docs/openapi/link), [Specification](/docs/openapi/specification), [Scalar](/docs/openapi/scalar) | +| [@orpc/server](https://npmx.dev/package-docs/@orpc/server) | Build APIs or implement contracts. | [Procedure](/docs/procedure), [Router](/docs/router), [Middleware](/docs/middleware), [Context](/docs/context), [RPC Handler](/docs/rpc/handler) | +| [@orpc/client](https://npmx.dev/package-docs/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](/docs/client/client-side), [Server-Side Clients](/docs/client/server-side), [RPC Link](/docs/rpc/link), [Error Handling](/docs/client/error-handling) | +| [@orpc/openapi](https://npmx.dev/package-docs/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](/docs/openapi/handler), [OpenAPI Link](/docs/openapi/link), [Specification](/docs/openapi/specification), [Scalar](/docs/openapi/scalar) | ## Schema Validation -| Package | Purpose | Related Guides | -| ------- | ------- | -------------- | -| [@orpc/zod](https://npmx.dev/package-docs/@orpc/zod) | JSON Schema converter for Zod. | [Zod](/docs/integrations/zod) | +| Package | Purpose | Related Guides | +| ------------------------------------------------------------ | ---------------------------------- | ------------------------------------- | +| [@orpc/zod](https://npmx.dev/package-docs/@orpc/zod) | JSON Schema converter for Zod. | [Zod](/docs/integrations/zod) | | [@orpc/valibot](https://npmx.dev/package-docs/@orpc/valibot) | JSON Schema converter for Valibot. | [Valibot](/docs/integrations/valibot) | | [@orpc/arktype](https://npmx.dev/package-docs/@orpc/arktype) | JSON Schema converter for ArkType. | [ArkType](/docs/integrations/arktype) | ## Built-in Features -| Package | Purpose | Related Guides | -| ------- | ------- | -------------- | -| [@orpc/publisher](https://npmx.dev/package-docs/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](/docs/helpers/publisher) | -| [@orpc/ratelimit](https://npmx.dev/package-docs/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://npmx.dev/package-docs/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](/docs/helpers/lock) | -| [@orpc/hibernation](https://npmx.dev/package-docs/@orpc/hibernation) | Leverage Hibernation APIs like Cloudflare's WebSocket Hibernation. | [Hibernation](/docs/integrations/hibernation) | -| [@orpc/json-schema](https://npmx.dev/package-docs/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](/docs/plugins/smart-coercion) | +| Package | Purpose | Related Guides | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------- | +| [@orpc/publisher](https://npmx.dev/package-docs/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](/docs/helpers/publisher) | +| [@orpc/ratelimit](https://npmx.dev/package-docs/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](/docs/helpers/ratelimit) | +| [@orpc/experimental-lock](https://npmx.dev/package-docs/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](/docs/helpers/lock) | +| [@orpc/hibernation](https://npmx.dev/package-docs/@orpc/hibernation) | Leverage Hibernation APIs like Cloudflare's WebSocket Hibernation. | [Hibernation](/docs/integrations/hibernation) | +| [@orpc/json-schema](https://npmx.dev/package-docs/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](/docs/plugins/smart-coercion) | ## Framework and Ecosystem Integrations -| Package | Purpose | Related Guides | -| ------- | ------- | -------------- | -| [@orpc/next](https://npmx.dev/package-docs/@orpc/next) | Integrate with Next.js Server Functions. | [Next.js](/docs/integrations/next) | -| [@orpc/ai-sdk](https://npmx.dev/package-docs/@orpc/ai-sdk) | Turn contracts and procedures into AI SDK tools. | [AI SDK](/docs/integrations/ai-sdk) | -| [@orpc/mcp](https://npmx.dev/package-docs/@orpc/mcp) | Expose procedures as MCP tools. | [MCP](/docs/integrations/mcp) | -| [@orpc/tanstack-query](https://npmx.dev/package-docs/@orpc/tanstack-query) | Integrate with TanStack Query. | [TanStack Query](/docs/integrations/tanstack-query) | -| [@orpc/pinia-colada](https://npmx.dev/package-docs/@orpc/pinia-colada) | Integrate with Pinia Colada. | [Pinia Colada](/docs/integrations/pinia-colada) | -| [@orpc/swr](https://npmx.dev/package-docs/@orpc/swr) | Integrate with SWR. | [SWR](/docs/integrations/swr) | -| [@orpc/effect](https://npmx.dev/package-docs/@orpc/effect) | Integrate with Effect. | [Effect](/docs/integrations/effect) | -| [@orpc/msw](https://npmx.dev/package-docs/@orpc/msw) | Mock procedures at the network level with typed MSW request handlers. | [MSW](/docs/integrations/msw) | -| [@orpc/nest](https://npmx.dev/package-docs/@orpc/nest) | Implement your contract with NestJS. | [NestJS](/docs/integrations/nest) | -| [@orpc/node](https://npmx.dev/package-docs/@orpc/node) | Node.js plugins for static file serving and large uploads. | [Static File](/docs/plugins/static-file), [Tmp File Upload](/docs/plugins/tmp-file-upload), [Batch Response Compression](/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Publisher, Rate Limit, and Lock. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Lock](/docs/helpers/lock) | -| [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Adapters for Cloudflare Workers. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Lock](/docs/helpers/lock), [Traces](/docs/integrations/cloudflare-traces) | -| [@orpc/trpc](https://npmx.dev/package-docs/@orpc/trpc) | Reuse existing tRPC routers within oRPC. | [tRPC](/docs/integrations/trpc) | +| Package | Purpose | Related Guides | +| -------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [@orpc/next](https://npmx.dev/package-docs/@orpc/next) | Integrate with Next.js Server Functions. | [Next.js](/docs/integrations/next) | +| [@orpc/ai-sdk](https://npmx.dev/package-docs/@orpc/ai-sdk) | Turn contracts and procedures into AI SDK tools. | [AI SDK](/docs/integrations/ai-sdk) | +| [@orpc/mcp](https://npmx.dev/package-docs/@orpc/mcp) | Expose procedures as MCP tools. | [MCP](/docs/integrations/mcp) | +| [@orpc/tanstack-query](https://npmx.dev/package-docs/@orpc/tanstack-query) | Integrate with TanStack Query. | [TanStack Query](/docs/integrations/tanstack-query) | +| [@orpc/pinia-colada](https://npmx.dev/package-docs/@orpc/pinia-colada) | Integrate with Pinia Colada. | [Pinia Colada](/docs/integrations/pinia-colada) | +| [@orpc/swr](https://npmx.dev/package-docs/@orpc/swr) | Integrate with SWR. | [SWR](/docs/integrations/swr) | +| [@orpc/effect](https://npmx.dev/package-docs/@orpc/effect) | Integrate with Effect. | [Effect](/docs/integrations/effect) | +| [@orpc/msw](https://npmx.dev/package-docs/@orpc/msw) | Mock procedures at the network level with typed MSW request handlers. | [MSW](/docs/integrations/msw) | +| [@orpc/nest](https://npmx.dev/package-docs/@orpc/nest) | Implement your contract with NestJS. | [NestJS](/docs/integrations/nest) | +| [@orpc/node](https://npmx.dev/package-docs/@orpc/node) | Node.js plugins for static file serving and large uploads. | [Static File](/docs/plugins/static-file), [Tmp File Upload](/docs/plugins/tmp-file-upload), [Batch Response Compression](/docs/plugins/batch-response-compression) | +| [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Publisher, Rate Limit, and Lock. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Lock](/docs/helpers/lock) | +| [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Adapters for Cloudflare Workers. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Lock](/docs/helpers/lock), [Traces](/docs/integrations/cloudflare-traces) | +| [@orpc/trpc](https://npmx.dev/package-docs/@orpc/trpc) | Reuse existing tRPC routers within oRPC. | [tRPC](/docs/integrations/trpc) | ## Observability -| Package | Purpose | Related Guides | -| ------- | ------- | -------------- | +| Package | Purpose | Related Guides | +| ------------------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------- | | [@orpc/opentelemetry](https://npmx.dev/package-docs/@orpc/opentelemetry) | Distributed tracing with OpenTelemetry. | [OpenTelemetry](/docs/integrations/opentelemetry) | -| [@orpc/pino](https://npmx.dev/package-docs/@orpc/pino) | Logging with Pino. | [Pino](/docs/integrations/pino) | -| [@orpc/evlog](https://npmx.dev/package-docs/@orpc/evlog) | Logging with Evlog. | [Evlog](/docs/integrations/evlog) | +| [@orpc/pino](https://npmx.dev/package-docs/@orpc/pino) | Logging with Pino. | [Pino](/docs/integrations/pino) | +| [@orpc/evlog](https://npmx.dev/package-docs/@orpc/evlog) | Logging with Evlog. | [Evlog](/docs/integrations/evlog) | diff --git a/apps/content/docs/async-iterator-object.mdx b/apps/content/docs/async-iterator-object.mdx index b91ceb5962..f59bc03fc6 100644 --- a/apps/content/docs/async-iterator-object.mdx +++ b/apps/content/docs/async-iterator-object.mdx @@ -1,6 +1,6 @@ --- -title: "AsyncIteratorObject (SSE)" -description: "Stream realtime typesafe data from oRPC procedures with async generators, event validation, resume support, and cleanup on disconnect." +title: 'AsyncIteratorObject (SSE)' +description: 'Stream realtime typesafe data from oRPC procedures with async generators, event validation, resume support, and cleanup on disconnect.' --- ## Overview @@ -8,14 +8,13 @@ description: "Stream realtime typesafe data from oRPC procedures with async gene An `AsyncIteratorObject` 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, signal, lastEventId }) { - while (true) { - signal?.throwIfAborted() - yield { message: 'Hello, world!' } - await new Promise(resolve => setTimeout(resolve, 1000)) - } - }) +const example = os.handler(async function* ({ input, signal, lastEventId }) { + while (true) { + signal?.throwIfAborted() + yield { message: 'Hello, world!' } + await new Promise((resolve) => setTimeout(resolve, 1000)) + } +}) ``` :::info @@ -35,7 +34,7 @@ const example = os while (true) { signal?.throwIfAborted() yield { message: 'Hello, world!' } - await new Promise(resolve => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 1000)) } }) ``` @@ -51,22 +50,17 @@ When used with the [Retry Plugin](/docs/plugins/retry) or [EventSource](https:// ```ts import { withEventMeta } from '@orpc/server' -const example = os - .handler(async function* ({ input, signal, lastEventId }) { - if (lastEventId) { - // Resume streaming from lastEventId - } - else { - while (true) { - signal?.throwIfAborted() - yield withEventMeta( - { message: 'Hello, world!' }, - { id: 'some-id', retry: 10_000 } - ) - await new Promise(resolve => setTimeout(resolve, 1000)) - } +const example = os.handler(async function* ({ input, signal, lastEventId }) { + if (lastEventId) { + // Resume streaming from lastEventId + } else { + while (true) { + signal?.throwIfAborted() + yield withEventMeta({ message: 'Hello, world!' }, { id: 'some-id', retry: 10_000 }) + await new Promise((resolve) => setTimeout(resolve, 1000)) } - }) + } +}) ``` ## Stop AsyncIteratorObject @@ -78,19 +72,18 @@ This behavior is specific to oRPC. Standard [SSE](https://developer.mozilla.org/ ::: ```ts -const example = os - .handler(async function* ({ input, signal, lastEventId }) { - while (true) { - signal?.throwIfAborted() +const example = os.handler(async function* ({ input, signal, lastEventId }) { + while (true) { + signal?.throwIfAborted() - if (done) { - return - } - - yield { message: 'Hello, world!' } - await new Promise(resolve => setTimeout(resolve, 1000)) + if (done) { + return } - }) + + yield { message: 'Hello, world!' } + await new Promise((resolve) => setTimeout(resolve, 1000)) + } +}) ``` ## Signal and Side-Effects @@ -98,19 +91,17 @@ const example = os 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, signal, lastEventId }) { - try { - while (true) { - signal?.throwIfAborted() - yield { message: 'Hello, world!' } - await new Promise(resolve => setTimeout(resolve, 1000)) - } - } - finally { - console.log('Cleanup logic here') +const example = os.handler(async function* ({ input, signal, lastEventId }) { + try { + while (true) { + signal?.throwIfAborted() + yield { message: 'Hello, world!' } + await new Promise((resolve) => setTimeout(resolve, 1000)) } - }) + } finally { + console.log('Cleanup logic here') + } +}) ``` :::info @@ -128,18 +119,15 @@ const publisher = new MemoryPublisher<{ } }>() -const live = os - .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 - } - }) +const live = os.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 + } +}) -const publish = os - .input(z.object({ id: z.string() })) - .handler(async ({ input }) => { - await publisher.publish('something-updated', { id: input.id }) - }) +const publish = os.input(z.object({ id: z.string() })).handler(async ({ input }) => { + await publisher.publish('something-updated', { id: input.id }) +}) ``` diff --git a/apps/content/docs/binary-data.mdx b/apps/content/docs/binary-data.mdx index eb02d95daf..d90fbdd97d 100644 --- a/apps/content/docs/binary-data.mdx +++ b/apps/content/docs/binary-data.mdx @@ -1,6 +1,6 @@ --- -title: "Binary Data" -description: "Learn how oRPC procedures accept and return File, Blob, and binary streams, and how to configure CORS headers for cross-origin binary responses." +title: 'Binary Data' +description: 'Learn how oRPC procedures accept and return File, Blob, and binary streams, and how to configure CORS headers for cross-origin binary responses.' --- [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. @@ -56,18 +56,16 @@ 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 +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/async-iterator-object.mdx b/apps/content/docs/client/async-iterator-object.mdx index 7ff3716f0c..33f59844c9 100644 --- a/apps/content/docs/client/async-iterator-object.mdx +++ b/apps/content/docs/client/async-iterator-object.mdx @@ -1,8 +1,8 @@ --- -title: "AsyncIteratorObject in Client" -description: "Consume an AsyncIteratorObject from the client like an AsyncGenerator, with stream cancellation, error handling, and event metadata." +title: 'AsyncIteratorObject in Client' +description: 'Consume an AsyncIteratorObject from the client like an AsyncGenerator, with stream cancellation, error handling, and event metadata.' sidebar: - label: "AsyncIteratorObject (SSE)" + label: 'AsyncIteratorObject (SSE)' --- ## Basic Usage @@ -14,7 +14,7 @@ import { asyncIteratorObject, oc, RouterContractClient } from '@orpc/contract' import { z } from 'zod' const contract = { - streaming: oc.output(asyncIteratorObject(z.object({ message: z.string() }))) + streaming: oc.output(asyncIteratorObject(z.object({ message: z.string() }))), } declare const client: RouterContractClient @@ -59,8 +59,7 @@ try { for await (const event of iterator) { console.log(event.message) } -} -catch (error) { +} catch (error) { if (error instanceof ORPCError) { // Handle the error here } diff --git a/apps/content/docs/client/client-side.mdx b/apps/content/docs/client/client-side.mdx index 63615b4469..a7865d0d0c 100644 --- a/apps/content/docs/client/client-side.mdx +++ b/apps/content/docs/client/client-side.mdx @@ -1,8 +1,8 @@ --- -title: "Client-Side Clients" -description: "Create oRPC clients that call procedures remotely over a link, with client context, interceptors, and type inference utilities." +title: 'Client-Side Clients' +description: 'Create oRPC clients that call procedures remotely over a link, with client context, interceptors, and type inference utilities.' sidebar: - label: "Client-Side" + label: 'Client-Side' --- ## Installation @@ -99,19 +99,20 @@ const client: RouterClient = createORPCClient(link } return data - } + }, ], scoped: { planet: { find: { - interceptors: [ // <- these interceptors only apply to client.planet.find + interceptors: [ + // <- these interceptors only apply to client.planet.find async ({ context, path, next }) => { return next() - } - ] - } - } - } + }, + ], + }, + }, + }, }) ``` diff --git a/apps/content/docs/client/dynamic-link.mdx b/apps/content/docs/client/dynamic-link.mdx index e5831c7a84..1155ab3a19 100644 --- a/apps/content/docs/client/dynamic-link.mdx +++ b/apps/content/docs/client/dynamic-link.mdx @@ -1,8 +1,8 @@ --- -title: "DynamicLink" -description: "Use DynamicLink to choose a link at runtime and route different oRPC requests through different links based on client context." +title: 'DynamicLink' +description: 'Use DynamicLink to choose a link at runtime and route different oRPC requests through different links based on client context.' sidebar: - label: "Dynamic Link" + label: 'Dynamic Link' --- ## Example diff --git a/apps/content/docs/client/error-handling.mdx b/apps/content/docs/client/error-handling.mdx index 64421d1b6d..c0db991e30 100644 --- a/apps/content/docs/client/error-handling.mdx +++ b/apps/content/docs/client/error-handling.mdx @@ -1,8 +1,8 @@ --- -title: "Client Error Handling" -description: "Handle client-side errors in oRPC with try/catch, or use safe and createSafeClient for fully type-inferred typesafe errors." +title: 'Client Error Handling' +description: 'Handle client-side errors in oRPC with try/catch, or use safe and createSafeClient for fully type-inferred typesafe errors.' sidebar: - label: "Error Handling" + label: 'Error Handling' --- ## Using `try/catch` @@ -12,8 +12,7 @@ For most calls, use regular `try/catch`. ```ts try { const data = await client.doSomething({ id: '123' }) -} -catch (error) { +} catch (error) { // handle error } ``` @@ -32,28 +31,25 @@ const exampleProcedure = os .input(z.object({ id: z.string() })) .errors({ RATE_LIMIT_EXCEEDED: { - data: z.object({ retryAfter: z.number() }) - } + data: z.object({ retryAfter: z.number() }), + }, }) .handler(async ({ input, errors }) => { throw errors.RATE_LIMIT_EXCEEDED({ data: { retryAfter: 1000 } }) }) // or { error, data, definedError } -const [error, data, definedError] = await safe( - call(exampleProcedure, { id: '123' }) -) +const [error, data, definedError] = await safe(call(exampleProcedure, { id: '123' })) -if (isDefinedError(error)) { // or definedError +if (isDefinedError(error)) { + // or definedError // handle defined error // or definedError.data.retryAfter console.log(error.data.retryAfter) -} -else if (error) { +} else if (error) { // handle unknown error -} -else { +} else { // handle success console.log(data) } diff --git a/apps/content/docs/client/index.mdx b/apps/content/docs/client/index.mdx index 22d2ec6726..d34a4e2180 100644 --- a/apps/content/docs/client/index.mdx +++ b/apps/content/docs/client/index.mdx @@ -1,8 +1,8 @@ --- -title: "Client" -description: "Call oRPC procedures with full type safety from browsers, apps, and servers: create clients over links, call locally during SSR, handle errors, and stream." +title: 'Client' +description: 'Call oRPC procedures with full type safety from browsers, apps, and servers: create clients over links, call locally during SSR, handle errors, and stream.' seo: - title: "Clients: Call Procedures with End-to-End Types" + title: 'Clients: Call Procedures with End-to-End Types' sidebar: hidden: true --- diff --git a/apps/content/docs/client/meta.ts b/apps/content/docs/client/meta.ts index b4c1979909..c8b079f680 100644 --- a/apps/content/docs/client/meta.ts +++ b/apps/content/docs/client/meta.ts @@ -1,11 +1,5 @@ import { defineMeta } from 'blume' export default defineMeta({ - pages: [ - 'server-side', - 'client-side', - 'error-handling', - 'async-iterator-object', - 'dynamic-link', - ], + pages: ['server-side', 'client-side', 'error-handling', 'async-iterator-object', 'dynamic-link'], }) diff --git a/apps/content/docs/client/server-side.mdx b/apps/content/docs/client/server-side.mdx index bd56797549..de47478911 100644 --- a/apps/content/docs/client/server-side.mdx +++ b/apps/content/docs/client/server-side.mdx @@ -1,8 +1,8 @@ --- -title: "Server-Side Clients" -description: "Call oRPC procedures locally in the same process with call, createRouterClient, or the callable extension, ideal for server-side code." +title: 'Server-Side Clients' +description: 'Call oRPC procedures locally in the same process with call, createRouterClient, or the callable extension, ideal for server-side code.' sidebar: - label: "Server-Side" + label: 'Server-Side' --- ## One-Off Calls @@ -12,14 +12,12 @@ Use `call` when you need to invoke a single procedure without creating a client ```ts twoslash import * as z from 'zod' -const exampleProcedure = os - .input(z.string()) - .handler(async ({ input }) => ({ id: input })) +const exampleProcedure = os.input(z.string()).handler(async ({ input }) => ({ id: input })) // ---cut--- import { call, os } from '@orpc/server' const result = await call(exampleProcedure, 'input', { - context: {} // <- provide initial context if needed + context: {}, // <- provide initial context if needed }) ``` @@ -46,16 +44,14 @@ const client = createRouterClient(router, { try { return await next() - } - catch (err) { + } catch (err) { console.error(`${path.join('.')}:`, err) throw err - } - finally { + } finally { console.timeEnd(path.join('.')) } - } - ] + }, + ], }) const result = await client.ping() @@ -79,13 +75,14 @@ interface ClientContext { } const client = createRouterClient(router, { - context: ({ cache }: ClientContext) => { // [!code highlight] + context: ({ cache }: ClientContext) => { + // [!code highlight] if (cache) { return {} // <- context when cache enabled } return {} - } + }, }) const result = await client.ping(undefined, { context: { cache: true } }) @@ -104,16 +101,14 @@ const client = createRouterClient(router, { try { const output = await next() return output - } - catch (err) { + } catch (err) { console.error(`${path.join('.')}:`, err) throw err - } - finally { + } finally { console.timeEnd(path.join('.')) } - } - ] + }, + ], }) ``` @@ -125,7 +120,7 @@ Import `@orpc/server/extensions/callable` from a module that always runs during ```ts usage const ping = base - .input(z.object({ name: z.string(), })) + .input(z.object({ name: z.string() })) .handler(async ({ input }) => `Hello ${input.name}!`) .callable({ context: async () => ({}), // <- provide initial context if needed, can be async function diff --git a/apps/content/docs/comparison.mdx b/apps/content/docs/comparison.mdx index e9ab5c61c3..120081a7fd 100644 --- a/apps/content/docs/comparison.mdx +++ b/apps/content/docs/comparison.mdx @@ -1,8 +1,8 @@ --- -title: "oRPC vs tRPC vs Hono" -description: "See how oRPC compares to tRPC and Hono across type safety, OpenAPI support, framework integrations, and runtime performance." +title: 'oRPC vs tRPC vs Hono' +description: 'See how oRPC compares to tRPC and Hono across type safety, OpenAPI support, framework integrations, and runtime performance.' sidebar: - label: "Comparison" + label: 'Comparison' icon: scale --- @@ -18,55 +18,55 @@ Checked against tRPC 11.19 and Hono 4.13 (October 2026). ### Type Safety -| Feature | oRPC | tRPC | Hono | -| --- | --- | --- | --- | -| End-to-end typesafe input/output | ✅ | ✅ | ✅ | -| [End-to-end typesafe errors](/docs/client/error-handling) | ✅ | 🟡 | ✅ | -| [End-to-end typesafe File/Blob](/docs/binary-data) | ✅ | 🟡 | 🟡 | -| [End-to-end typesafe Server-Sent Events](/docs/async-iterator-object) | ✅ | ✅ | 🛑 | -| [End-to-end typesafe `ReadableStream`](/docs/binary-data#readablestreamuint8array) | ✅ | 🛑 | 🛑 | -| [Typesafe at scale](#type-checking-performance) | ✅ | ✅ | 🛑 | +| Feature | oRPC | tRPC | Hono | +| ---------------------------------------------------------------------------------------------- | ---- | ---- | ---- | +| End-to-end typesafe input/output | ✅ | ✅ | ✅ | +| [End-to-end typesafe errors](/docs/client/error-handling) | ✅ | 🟡 | ✅ | +| [End-to-end typesafe File/Blob](/docs/binary-data) | ✅ | 🟡 | 🟡 | +| [End-to-end typesafe Server-Sent Events](/docs/async-iterator-object) | ✅ | ✅ | 🛑 | +| [End-to-end typesafe `ReadableStream`](/docs/binary-data#readablestreamuint8array) | ✅ | 🛑 | 🛑 | +| [Typesafe at scale](#type-checking-performance) | ✅ | ✅ | 🛑 | oRPC types errors per procedure or globally: throw a plain `ORPCError`, [declare errors](/docs/error-handling) on a single procedure or a shared builder, or define them once and reuse them everywhere via an [error factory](/docs/error-handling#error-factory). tRPC (as of 11.19) exposes one global error shape, and Hono only types errors returned with an explicit status code. oRPC is also the only one that types binary data in both directions: tRPC and Hono cover uploads but not typed binary responses. ### API Design & OpenAPI -| Feature | oRPC | tRPC | Hono | -| --- | --- | --- | --- | -| Implementation-first approach | ✅ | ✅ | ✅ | -| [Contract-first approach](/docs/contract-first) | ✅ | 🛑 | 🟡 | -| [OpenAPI spec generation](/docs/openapi/specification) | ✅ | 🟡 | 🟡 | -| [Standard Schema (Zod, Valibot, ArkType, ...)](/docs/integrations/standard-schema) | ✅ | ✅ | 🟡 | -| [Native types (Date, URL, Set, Map, ...)](/docs/rpc/serializer#supported-data-types) | ✅ | 🟡 | 🛑 | -| [Custom serializers](/docs/rpc/serializer#custom-serializers) | ✅ | ✅ | 🛑 | -| [Bracket notation](/docs/openapi/bracket-notation) | ✅ | 🛑 | 🛑 | -| [Lazy router (code splitting, faster cold starts)](/docs/router#lazy-router) | ✅ | ✅ | 🛑 | +| Feature | oRPC | tRPC | Hono | +| ------------------------------------------------------------------------------------ | ---- | ---- | ---- | +| Implementation-first approach | ✅ | ✅ | ✅ | +| [Contract-first approach](/docs/contract-first) | ✅ | 🛑 | 🟡 | +| [OpenAPI spec generation](/docs/openapi/specification) | ✅ | 🟡 | 🟡 | +| [Standard Schema (Zod, Valibot, ArkType, ...)](/docs/integrations/standard-schema) | ✅ | ✅ | 🟡 | +| [Native types (Date, URL, Set, Map, ...)](/docs/rpc/serializer#supported-data-types) | ✅ | 🟡 | 🛑 | +| [Custom serializers](/docs/rpc/serializer#custom-serializers) | ✅ | ✅ | 🛑 | +| [Bracket notation](/docs/openapi/bracket-notation) | ✅ | 🛑 | 🛑 | +| [Lazy router (code splitting, faster cold starts)](/docs/router#lazy-router) | ✅ | ✅ | 🛑 | oRPC serves the same procedures over its [RPC protocol](/docs/rpc/protocol) and RESTful [OpenAPI endpoints](/docs/openapi/handler), and generates the spec with a first-party package. tRPC's `@trpc/openapi` is alpha and covers only tRPC-style endpoints, and Hono relies on third-party middleware like `@hono/zod-openapi`. Native types like `Date` or `Map` work out of the box in oRPC; tRPC needs a transformer, and Hono has no serialization layer: what you pass to `c.json()` is what you get. ### Integrations & Ecosystem -| Feature | oRPC | tRPC | Hono | -| --- | --- | --- | --- | -| [WebSockets](/docs/adapters/websocket) | ✅ | ✅ | 🟡 | -| [Hibernation APIs (Cloudflare WebSocket Hibernation, ...)](/docs/integrations/hibernation) | ✅ | 🛑 | 🛑 | -| [Message Port (Electron, browser extensions, workers, ...)](/docs/adapters/message-port) | ✅ | 🟡 | 🛑 | -| [Batch requests](/docs/plugins/batch) | ✅ | ✅ | 🛑 | -| [Pub/Sub publisher (Redis, Upstash, Durable Objects, ...)](/docs/helpers/publisher) | ✅ | 🟡 | 🛑 | -| [Rate limiting (Redis, Upstash, Cloudflare, ...)](/docs/helpers/ratelimit) | ✅ | 🛑 | 🟡 | -| [Locking (Redis, Upstash, Durable Objects, ...)](/docs/helpers/lock) | ✅ | 🛑 | 🛑 | -| [Client plugins and interceptors](/docs/rpc/link) | ✅ | ✅ | 🛑 | -| [TanStack Query integration (React)](/docs/integrations/tanstack-query) | ✅ | ✅ | 🟡 | -| [TanStack Query integration (Vue, Solid, Svelte, Angular)](/docs/integrations/tanstack-query) | ✅ | 🟡 | 🛑 | -| [SWR and Pinia Colada integrations](/docs/integrations/swr) | ✅ | 🟡 | 🟡 | -| [AI SDK integration](/docs/integrations/ai-sdk) | ✅ | 🛑 | 🛑 | -| [MCP integration (procedures as AI agent tools)](/docs/integrations/mcp) | ✅ | 🟡 | 🟡 | -| [Next.js Server Actions support](/docs/integrations/next) | ✅ | ✅ | 🛑 | -| [Built-in plugins (CORS, CSRF, Retry, ...)](/docs/plugins/cors) | ✅ | 🟡 | ✅ | -| [Built-in helpers (cookie, encryption, signing, ...)](/docs/helpers/cookie) | ✅ | 🛑 | ✅ | -| [NestJS integration](/docs/integrations/nest) | ✅ | 🟡 | 🛑 | -| [OpenTelemetry integration](/docs/integrations/opentelemetry) | ✅ | 🛑 | 🟡 | -| [Cloudflare Workers Traces integration](/docs/integrations/cloudflare-traces) | ✅ | 🛑 | 🛑 | +| Feature | oRPC | tRPC | Hono | +| --------------------------------------------------------------------------------------------- | ---- | ---- | ---- | +| [WebSockets](/docs/adapters/websocket) | ✅ | ✅ | 🟡 | +| [Hibernation APIs (Cloudflare WebSocket Hibernation, ...)](/docs/integrations/hibernation) | ✅ | 🛑 | 🛑 | +| [Message Port (Electron, browser extensions, workers, ...)](/docs/adapters/message-port) | ✅ | 🟡 | 🛑 | +| [Batch requests](/docs/plugins/batch) | ✅ | ✅ | 🛑 | +| [Pub/Sub publisher (Redis, Upstash, Durable Objects, ...)](/docs/helpers/publisher) | ✅ | 🟡 | 🛑 | +| [Rate limiting (Redis, Upstash, Cloudflare, ...)](/docs/helpers/ratelimit) | ✅ | 🛑 | 🟡 | +| [Locking (Redis, Upstash, Durable Objects, ...)](/docs/helpers/lock) | ✅ | 🛑 | 🛑 | +| [Client plugins and interceptors](/docs/rpc/link) | ✅ | ✅ | 🛑 | +| [TanStack Query integration (React)](/docs/integrations/tanstack-query) | ✅ | ✅ | 🟡 | +| [TanStack Query integration (Vue, Solid, Svelte, Angular)](/docs/integrations/tanstack-query) | ✅ | 🟡 | 🛑 | +| [SWR and Pinia Colada integrations](/docs/integrations/swr) | ✅ | 🟡 | 🟡 | +| [AI SDK integration](/docs/integrations/ai-sdk) | ✅ | 🛑 | 🛑 | +| [MCP integration (procedures as AI agent tools)](/docs/integrations/mcp) | ✅ | 🟡 | 🟡 | +| [Next.js Server Actions support](/docs/integrations/next) | ✅ | ✅ | 🛑 | +| [Built-in plugins (CORS, CSRF, Retry, ...)](/docs/plugins/cors) | ✅ | 🟡 | ✅ | +| [Built-in helpers (cookie, encryption, signing, ...)](/docs/helpers/cookie) | ✅ | 🛑 | ✅ | +| [NestJS integration](/docs/integrations/nest) | ✅ | 🟡 | 🛑 | +| [OpenTelemetry integration](/docs/integrations/opentelemetry) | ✅ | 🛑 | 🟡 | +| [Cloudflare Workers Traces integration](/docs/integrations/cloudflare-traces) | ✅ | 🛑 | 🛑 | Every oRPC integration above is first-party, while tRPC leaves several to community packages and Hono's typed client is a bare fetch wrapper you wire into query libraries yourself. @@ -84,11 +84,11 @@ All numbers come from [middleapi/orpc-benchmarks](https://github.com/middleapi/o ### Throughput -| Scenario | oRPC | tRPC | Hono | -| --- | --- | --- | --- | -| RPC over HTTP (req/s avg) | 20,279 | 4,821 | n/a | -| RPC over WebSocket (req/s avg) | 22,979 | 5,228 | n/a | -| OpenAPI (RESTful) over HTTP (req/s avg) | 19,799 | n/a | 18,694 | +| Scenario | oRPC | tRPC | Hono | +| --------------------------------------- | ------ | ----- | ------ | +| RPC over HTTP (req/s avg) | 20,279 | 4,821 | n/a | +| RPC over WebSocket (req/s avg) | 22,979 | 5,228 | n/a | +| OpenAPI (RESTful) over HTTP (req/s avg) | 19,799 | n/a | 18,694 | oRPC handles about 4.2x as many requests as tRPC over HTTP and 4.4x over WebSocket. oRPC and Hono are close on RESTful throughput, with oRPC about 6% ahead. @@ -96,14 +96,14 @@ oRPC handles about 4.2x as many requests as tRPC over HTTP and 4.4x over WebSock Every run also profiles the server with [Clinic.js Doctor](https://clinicjs.org/doctor/): -| Run | CPU (avg) | Memory (RSS) | Detected issues | Full report | -| --- | --- | --- | --- | --- | -| RPC over HTTP · oRPC | ~104% | 71-87 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/rpc-orpc/.clinic/report.clinic-doctor.html) | -| RPC over HTTP · tRPC | ~113% | 75-248 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/rpc-trpc/.clinic/report.clinic-doctor.html) | -| RPC over WebSocket · oRPC | ~104% | 72-107 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/ws-orpc/.clinic/report.clinic-doctor.html) | -| RPC over WebSocket · tRPC | ~40% | 68-77 MB | cpu: performance | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/ws-trpc/.clinic/report.clinic-doctor.html) | -| OpenAPI over HTTP · oRPC | ~104% | 72-90 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/openapi-orpc/.clinic/report.clinic-doctor.html) | -| OpenAPI over HTTP · Hono | ~103% | 77-95 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/openapi-hono/.clinic/report.clinic-doctor.html) | +| Run | CPU (avg) | Memory (RSS) | Detected issues | Full report | +| ------------------------- | --------- | ------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RPC over HTTP · oRPC | ~104% | 71-87 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/rpc-orpc/.clinic/report.clinic-doctor.html) | +| RPC over HTTP · tRPC | ~113% | 75-248 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/rpc-trpc/.clinic/report.clinic-doctor.html) | +| RPC over WebSocket · oRPC | ~104% | 72-107 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/ws-orpc/.clinic/report.clinic-doctor.html) | +| RPC over WebSocket · tRPC | ~40% | 68-77 MB | cpu: performance | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/ws-trpc/.clinic/report.clinic-doctor.html) | +| OpenAPI over HTTP · oRPC | ~104% | 72-90 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/openapi-orpc/.clinic/report.clinic-doctor.html) | +| OpenAPI over HTTP · Hono | ~103% | 77-95 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/openapi-hono/.clinic/report.clinic-doctor.html) | The profiles show where the throughput gap comes from. Over HTTP, oRPC serves over 4x tRPC's requests at similar CPU usage (~104% vs ~113%), so its per-request CPU cost is several times lower, and its event loop stays responsive: 0.04 ms average delay versus tRPC's 0.77 ms. Memory stays flat between 71 and 107 MB in every oRPC run, while tRPC's HTTP run climbs to 248 MB, more than double oRPC's highest reading, a sign of per-request allocation pressure. oRPC and Hono profile nearly identically, in line with their close throughput. @@ -113,10 +113,10 @@ tRPC's low WebSocket CPU is not an advantage: the `cpu: performance` issue means On a large, fully typed project with 3,000 procedures across 1,501 routers, oRPC type-checks about 30% faster than tRPC, uses about 31% less memory, and needs about 19% fewer type instantiations: -| Metric | oRPC | tRPC | -| --- | --- | --- | -| Total time | 1.62s | 2.32s | -| Memory used | 469 MB | 677 MB | +| Metric | oRPC | tRPC | +| -------------- | --------- | --------- | +| Total time | 1.62s | 2.32s | +| Memory used | 469 MB | 677 MB | | Instantiations | 2,198,304 | 2,719,560 | Hono has no column because it does not scale to this size. Its RPC types hit [known limits](https://hono.dev/docs/guides/rpc#known-issues) well before this point and the project becomes effectively unusable, an issue oRPC avoids by design. @@ -125,9 +125,9 @@ Hono has no column because it does not scale to this size. Its RPC types hit [kn Bundle sizes for a minimal client and server pair, measured with the same versions. tRPC includes `superjson` to match oRPC's built-in native types, and Hono includes `@hono/node-server` to match the Node.js servers in the other bundles. -| Metric | oRPC | tRPC | Hono | -| --- | --- | --- | --- | -| Minified | 46.3 kB | 73.7 kB | 44.9 kB | +| Metric | oRPC | tRPC | Hono | +| --------------- | ------- | ------- | ------- | +| Minified | 46.3 kB | 73.7 kB | 44.9 kB | | Minified + gzip | 14.5 kB | 23.7 kB | 17.0 kB | Measure yourself: [oRPC](https://bundlejs.com/?q=%40orpc%2Fclient%402.0.0-beta.37%2C%40orpc%2Fclient%402.0.0-beta.37%2Ffetch%2C%40orpc%2Fserver%402.0.0-beta.37%2C%40orpc%2Fserver%402.0.0-beta.37%2Fnode&treeshake=%5B%7B+createORPCClient+%7D%5D%2C%5B%7B+RPCLink+%7D%5D%2C%5B%7B+os+%7D%5D%2C%5B%7B+RPCHandler+%7D%5D) · [tRPC](https://bundlejs.com/?q=%40trpc%2Fclient%4011.19.0%2C%40trpc%2Fserver%4011.19.0%2C%40trpc%2Fserver%4011.19.0%2Fadapters%2Fstandalone%2Csuperjson%402.2.6&treeshake=%5B%7B+createTRPCClient%2ChttpLink%2ChttpSubscriptionLink%2CsplitLink+%7D%5D%2C%5B%7B+initTRPC+%7D%5D%2C%5B%7B+createHTTPServer+%7D%5D%2C%5B%7B+default+as+SuperJSON+%7D%5D) · [Hono](https://bundlejs.com/?q=hono%404.13.8%2Chono%404.13.8%2Fclient%2C%40hono%2Fnode-server%402.1.1&treeshake=%5B%7B+Hono+%7D%5D%2C%5B%7B+hc+%7D%5D%2C%5B%7B+serve+%7D%5D) diff --git a/apps/content/docs/context.mdx b/apps/content/docs/context.mdx index 8d33eac8da..10b037fbe9 100644 --- a/apps/content/docs/context.mdx +++ b/apps/content/docs/context.mdx @@ -1,6 +1,6 @@ --- -title: "Context" -description: "Use oRPC context for type-safe dependency injection, providing initial context explicitly or injecting values through middleware." +title: 'Context' +description: 'Use oRPC context for type-safe dependency injection, providing initial context explicitly or injecting values through middleware.' --- ## Initial Context @@ -12,10 +12,9 @@ import { os } from '@orpc/server' // ---cut--- const base = os.$context<{ env: { DB_URL: string } }>() -export const getting = base - .handler(async ({ context }) => { - console.log(context.env) - }) +export const getting = base.handler(async ({ context }) => { + console.log(context.env) +}) ``` :::info @@ -28,7 +27,8 @@ 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 + context: { + // <- initial context must be passed when calling env: { DB_URL: 'postgres://...' }, }, }) @@ -57,11 +57,13 @@ import { os } from '@orpc/server' declare const env: { DB_URL: string } // ---cut--- -const base = os.use(async ({ next }) => next({ - context: { - env: { DB_URL: env.DB_URL }, - }, -})) +const base = os.use(async ({ next }) => + next({ + context: { + env: { DB_URL: env.DB_URL }, + }, + }), +) export const getting = base.handler(async ({ context }) => { console.log(context.env) @@ -76,11 +78,13 @@ import { call, os } from '@orpc/server' declare const env: { DB_URL: string } -const base = os.use(async ({ next }) => next({ - context: { - env: { DB_URL: env.DB_URL }, - }, -})) +const base = os.use(async ({ next }) => + next({ + context: { + env: { DB_URL: env.DB_URL }, + }, + }), +) const getting = base.handler(async ({ context }) => {}) // ---cut--- @@ -99,13 +103,10 @@ 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: { JWT_SECRET: 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], - context.env.JWT_SECRET - ) + const user = parseJWT(context.headers.get('authorization')?.split(' ')[1], context.env.JWT_SECRET) if (!user) { throw new ORPCError('UNAUTHORIZED') @@ -114,10 +115,8 @@ const requireAuth = base.middleware(async ({ context, next }) => { return next({ context: { user } }) }) -const getting = base - .use(requireAuth) - .handler(async ({ context }) => { - console.log(context.env) - console.log(context.user) - }) +const getting = base.use(requireAuth).handler(async ({ context }) => { + console.log(context.env) + console.log(context.user) +}) ``` diff --git a/apps/content/docs/contract-first.mdx b/apps/content/docs/contract-first.mdx index 60bb5764cd..1f7e652b39 100644 --- a/apps/content/docs/contract-first.mdx +++ b/apps/content/docs/contract-first.mdx @@ -1,6 +1,6 @@ --- -title: "Contract-First" -description: "Build a contract-first API with oRPC: define a contract, implement it with full type checking, and call it from a client that only needs the contract." +title: 'Contract-First' +description: 'Build a contract-first API with oRPC: define a contract, implement it with full type checking, and call it from a client that only needs the contract.' sidebar: icon: handshake --- @@ -41,12 +41,9 @@ const PlanetSchema = z.object({ description: z.string().optional(), }) -export const listPlanetsContract = oc - .output(z.array(PlanetSchema)) +export const listPlanetsContract = oc.output(z.array(PlanetSchema)) -export const findPlanetContract = oc - .input(z.object({ id: z.number() })) - .output(PlanetSchema) +export const findPlanetContract = oc.input(z.object({ id: z.number() })).output(PlanetSchema) export const createPlanetContract = oc .input(z.object({ name: z.string(), description: z.string().optional() })) @@ -80,26 +77,23 @@ import { implement } from '@orpc/server' const os = implement(contract) -export const listPlanets = os.planet.list - .handler(async () => { - // replace with your database query - return [ - { id: 1, name: 'Earth' }, - { id: 2, name: 'Mars' }, - ] - }) - -export const findPlanet = os.planet.find - .handler(async ({ input }) => { - // replace with your database query - return { id: input.id, name: 'Earth' } - }) - -export const createPlanet = os.planet.create - .handler(async ({ input }) => { - // replace with your database insert - return { id: 3, ...input } - }) +export const listPlanets = os.planet.list.handler(async () => { + // replace with your database query + return [ + { id: 1, name: 'Earth' }, + { id: 2, name: 'Mars' }, + ] +}) + +export const findPlanet = os.planet.find.handler(async ({ input }) => { + // replace with your database query + return { id: input.id, name: 'Earth' } +}) + +export const createPlanet = os.planet.create.handler(async ({ input }) => { + // replace with your database insert + return { id: 3, ...input } +}) export const router = os.router({ planet: { diff --git a/apps/content/docs/contract/client-factory.mdx b/apps/content/docs/contract/client-factory.mdx index 5a04985853..2304ec20de 100644 --- a/apps/content/docs/contract/client-factory.mdx +++ b/apps/content/docs/contract/client-factory.mdx @@ -1,6 +1,6 @@ --- -title: "Contract Client Factory" -description: "Create clients from individual procedure contracts instead of a single root contract, keeping large oRPC codebases fast and decoupled." +title: 'Contract Client Factory' +description: 'Create clients from individual procedure contracts instead of a single root contract, keeping large oRPC codebases fast and decoupled.' --- 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. @@ -30,7 +30,7 @@ const router = { procedure, }, }, - } + }, } ``` @@ -41,7 +41,7 @@ This pattern does not require a single root client. Instead, you configure a cli ```ts import { createContractClientFactory } from '@orpc/contract' -export const createClient = createContractClientFactory(link, { /** options */}) +export const createClient = createContractClientFactory(link, {/** options */}) ``` :::warning @@ -76,9 +76,7 @@ import { RouterContract } from '@orpc/contract' const contractRef: RouterContract = {} const link = new OpenAPILink(contractRef, { - plugins: [ - new PluginRequireContract(contractRef) - ] + plugins: [new PluginRequireContract(contractRef)], }) export const createClient = createContractJsonifiedClientFactory(link, { contractRef }) @@ -97,7 +95,7 @@ Some features may not support `contractRef` well. In those cases, import the roo ```ts import { createContractUtilsFactory } from '@orpc/tanstack-query' -export const createUtils = createContractUtilsFactory(createClient, { /** options */}) +export const createUtils = createContractUtilsFactory(createClient, {/** options */}) ``` :::warning @@ -131,7 +129,7 @@ const query = useQuery(utils.path.to.procedure.queryOptions({/** options */})) ```ts import { createContractUtilsFactory } from '@orpc/pinia-colada' -export const createUtils = createContractUtilsFactory(createClient, { /** options */}) +export const createUtils = createContractUtilsFactory(createClient, {/** options */}) ``` :::warning diff --git a/apps/content/docs/contract/generate-from-openapi.mdx b/apps/content/docs/contract/generate-from-openapi.mdx index c9d31d325b..35a8defe55 100644 --- a/apps/content/docs/contract/generate-from-openapi.mdx +++ b/apps/content/docs/contract/generate-from-openapi.mdx @@ -1,8 +1,8 @@ --- -title: "Generate Contract from OpenAPI" +title: 'Generate Contract from OpenAPI' description: "Generate an oRPC contract from an existing OpenAPI specification with Hey API's orpc plugin instead of writing it by hand." sidebar: - label: "Generate from OpenAPI" + label: 'Generate from OpenAPI' --- ## Overview @@ -55,12 +55,14 @@ import * as z from 'zod' import { zAddPetBody, zAddPetResponse } from './zod.gen' export const addPet = oc - .meta(openapi({ - inputStructure: 'detailed', - method: 'POST', - path: '/pet', - tags: ['pet'], - })) + .meta( + openapi({ + inputStructure: 'detailed', + method: 'POST', + path: '/pet', + tags: ['pet'], + }), + ) .input(z.object({ body: zAddPetBody })) .output(zAddPetResponse) diff --git a/apps/content/docs/contract/implementation.mdx b/apps/content/docs/contract/implementation.mdx index e61554e33b..17393fc8fa 100644 --- a/apps/content/docs/contract/implementation.mdx +++ b/apps/content/docs/contract/implementation.mdx @@ -1,6 +1,6 @@ --- -title: "Contract Implementation" -description: "Learn how to implement a contract with the implement function, adding type-checked handlers, middleware, and routers to every procedure." +title: 'Contract Implementation' +description: 'Learn how to implement a contract with the implement function, adding type-checked handlers, middleware, and routers to every procedure.' --- ## Implementer @@ -12,8 +12,7 @@ import { contract } from './shared/planet' // ---cut--- import { implement } from '@orpc/server' -const implementer = implement(contract) - .$context<{ something?: string }>() // <- define initial context +const implementer = implement(contract).$context<{ something?: string }>() // <- define initial context implementer.planet.list // ^| @@ -55,11 +54,9 @@ const listPlanet = implementer.planet.list 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 +const listPlanet = implementer.planet .use(requireAuth) // <- middleware wraps validation - .list - .handler(({ input }) => { + .list.handler(({ input }) => { // Your logic for listing planets return [] }) @@ -147,7 +144,7 @@ const listPlanets = pub.planet.list.handler(({ input }) => { const createPlanet = authed.planet.create.handler(({ input }) => { // Your logic for creating planets with authentication - return { } + return {} }) ``` diff --git a/apps/content/docs/contract/index.mdx b/apps/content/docs/contract/index.mdx index 11e05990d7..c9e8538c3e 100644 --- a/apps/content/docs/contract/index.mdx +++ b/apps/content/docs/contract/index.mdx @@ -1,8 +1,8 @@ --- -title: "Contract" -description: "Reference for oRPC contracts: describe procedures and routers without logic, implement them with type checking, generate them from OpenAPI, and scale clients." +title: 'Contract' +description: 'Reference for oRPC contracts: describe procedures and routers without logic, implement them with type checking, generate them from OpenAPI, and scale clients.' seo: - title: "Contracts: Define, Implement, and Share an API" + title: 'Contracts: Define, Implement, and Share an API' sidebar: hidden: true --- diff --git a/apps/content/docs/contract/meta.ts b/apps/content/docs/contract/meta.ts index f2472fb035..1c9dd1a003 100644 --- a/apps/content/docs/contract/meta.ts +++ b/apps/content/docs/contract/meta.ts @@ -1,11 +1,5 @@ import { defineMeta } from 'blume' export default defineMeta({ - pages: [ - 'procedure', - 'router', - 'implementation', - 'generate-from-openapi', - 'client-factory', - ], + pages: ['procedure', 'router', 'implementation', 'generate-from-openapi', 'client-factory'], }) diff --git a/apps/content/docs/contract/procedure.mdx b/apps/content/docs/contract/procedure.mdx index 8475d2e693..09933669f7 100644 --- a/apps/content/docs/contract/procedure.mdx +++ b/apps/content/docs/contract/procedure.mdx @@ -1,6 +1,6 @@ --- -title: "Procedure Contract" -description: "Define procedure contracts that describe input, output, errors, and metadata without business logic, keeping implementations aligned." +title: 'Procedure Contract' +description: 'Define procedure contracts that describe input, output, errors, and metadata without business logic, keeping implementations aligned.' --- ## Overview @@ -45,13 +45,9 @@ Unlike a [procedure](/docs/procedure), a contract has no `.handler` chain. If yo `.input` and `.output` can be called multiple times. Each call adds another schema instead of replacing an earlier one, and the value must satisfy all of them. ```ts -const base = oc - .input(z.object({ name: z.string() })) - .output(z.looseObject({ name: z.string() })) +const base = oc.input(z.object({ name: z.string() })).output(z.looseObject({ name: z.string() })) -const example = base - .input(z.object({ id: z.number() })) - .output(z.looseObject({ id: z.number() })) +const example = base.input(z.object({ id: z.number() })).output(z.looseObject({ id: z.number() })) ``` Object input schemas compose into a single flat value instead of being piped into each other, so a contract can extend a base contract without repeating its fields. Output schemas are still piped. Learn more in the [Procedure documentation](/docs/procedure#multiple-schemas). @@ -76,11 +72,9 @@ Each builder call creates a new instance, which avoids reference issues and make 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 pubExample = pub.input(z.object({ name: z.string() })) -const authedExample = authed - .input(z.object({ id: z.number() })) +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.mdx b/apps/content/docs/contract/router.mdx index 58929a001a..24aa0b960f 100644 --- a/apps/content/docs/contract/router.mdx +++ b/apps/content/docs/contract/router.mdx @@ -1,6 +1,6 @@ --- -title: "Router Contract" -description: "Define router contracts that describe the shape of a router without business logic, useful for documentation, testing, and aligned implementations." +title: 'Router Contract' +description: 'Define router contracts that describe the shape of a router without business logic, useful for documentation, testing, and aligned implementations.' --- :::info @@ -22,7 +22,7 @@ const pong = oc.output(z.string()) export const router = { ping, pong, - nested: { ping, pong } + nested: { ping, pong }, } ``` @@ -41,7 +41,7 @@ const router = oc.meta(requireAuthMeta).router({ nested: { ping, pong, - } + }, }) ``` @@ -55,6 +55,7 @@ const router = base.router({ list: base.output(z.array(z.string())), // prefix: '/api/api' }) ``` + ::: ## Router to Contract diff --git a/apps/content/docs/ecosystem.mdx b/apps/content/docs/ecosystem.mdx index 18b113333b..95c130cf31 100644 --- a/apps/content/docs/ecosystem.mdx +++ b/apps/content/docs/ecosystem.mdx @@ -1,6 +1,6 @@ --- title: Ecosystem -description: "Discover libraries, integrations, and community projects that extend oRPC across frameworks, runtimes, and tooling." +description: 'Discover libraries, integrations, and community projects that extend oRPC across frameworks, runtimes, and tooling.' sidebar: icon: network --- diff --git a/apps/content/docs/error-handling.mdx b/apps/content/docs/error-handling.mdx index 2f9e247dd9..ec029fe485 100644 --- a/apps/content/docs/error-handling.mdx +++ b/apps/content/docs/error-handling.mdx @@ -1,6 +1,6 @@ --- -title: "Error Handling" -description: "Handle errors in oRPC with the ORPCError class, typesafe error definitions, reusable error factories, and custom error conversion." +title: 'Error Handling' +description: 'Handle errors in oRPC with the ORPCError class, typesafe error definitions, reusable error factories, and custom error conversion.' --- ## `ORPCError` Class @@ -19,19 +19,17 @@ import { ORPCError, os } from '@orpc/server' const rateLimitMiddleware = os.middleware(async ({ next }) => { throw new ORPCError('RATE_LIMITED', { message: 'You are being rate limited', - data: { retryAfter: 60 } + data: { retryAfter: 60 }, }) return next() }) -const example = os - .use(rateLimitMiddleware) - .handler(async ({ input }) => { - if (notFound) { - throw new ORPCError('NOT_FOUND') - } - }) +const example = os.use(rateLimitMiddleware).handler(async ({ input }) => { + if (notFound) { + throw new ORPCError('NOT_FOUND') + } +}) ``` ## Typesafe Errors @@ -59,7 +57,7 @@ const rateLimitMiddleware = os .middleware(async ({ next, errors }) => { throw errors.RATE_LIMITED({ message: 'You are being rate limited', - data: { retryAfter: 60 } + data: { retryAfter: 60 }, }) return next() @@ -130,20 +128,18 @@ const RateLimitedError = error('RATE_LIMITED', { }), }) -const procedure = os - .handler(async () => { - throw new RateLimitedError({ data: { retryAfter: 60 } }) - }) +const procedure = os.handler(async () => { + throw new RateLimitedError({ data: { retryAfter: 60 } }) +}) ``` :::tip You can also register error factories in `.errors`. This makes them part of the [typesafe errors](#typesafe-errors) flow and visible in generated specifications. ```ts -const procedure = os - .errors({ - [RateLimitedError.code]: RateLimitedError, - }) +const procedure = os.errors({ + [RateLimitedError.code]: RateLimitedError, +}) ``` ::: @@ -163,7 +159,8 @@ if (err instanceof RateLimitedError) { 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' +declare module '@orpc/server' { + // or '@orpc/client' interface Registry { ORPCErrorCode: 'NOT_FOUND' | 'UNAUTHORIZED' | 'RATE_LIMITED' | 'MY_CUSTOM_ERROR' | (string & {}) } @@ -181,14 +178,12 @@ By default, oRPC can convert non-`ORPCError` into an `ORPCError` with code `INTE ::: ```ts -class MyCustomError extends Error { -} +class MyCustomError extends Error {} const customErrorConverterMiddleware = os.middleware(async ({ next }) => { try { return await next() - } - catch (err) { + } catch (err) { if (err instanceof MyCustomError) { throw new ORPCError('MY_CUSTOM_ERROR', { message: err.message, cause: err }) } diff --git a/apps/content/docs/getting-started.mdx b/apps/content/docs/getting-started.mdx index b4f718e73c..c14a501bb1 100644 --- a/apps/content/docs/getting-started.mdx +++ b/apps/content/docs/getting-started.mdx @@ -1,6 +1,6 @@ --- -title: "Getting Started" -description: "Build your first end-to-end typesafe API with oRPC, from defining procedures to serving and calling them from a client." +title: 'Getting Started' +description: 'Build your first end-to-end typesafe API with oRPC, from defining procedures to serving and calling them from a client.' sidebar: icon: rocket --- @@ -33,21 +33,18 @@ A procedure is a function that clients can call remotely. Build one with the `os import { os } from '@orpc/server' import * as z from 'zod' -export const listPlanets = os - .handler(async () => { - // replace with your database query - return [ - { id: 1, name: 'Earth' }, - { id: 2, name: 'Mars' }, - ] - }) +export const listPlanets = os.handler(async () => { + // replace with your database query + return [ + { id: 1, name: 'Earth' }, + { id: 2, name: 'Mars' }, + ] +}) -export const findPlanet = os - .input(z.object({ id: z.number() })) - .handler(async ({ input }) => { - // replace with your database query - return { id: input.id, name: 'Earth' } - }) +export const findPlanet = os.input(z.object({ id: z.number() })).handler(async ({ input }) => { + // replace with your database query + return { id: input.id, name: 'Earth' } +}) export const createPlanet = os .input(z.object({ name: z.string(), description: z.string().optional() })) diff --git a/apps/content/docs/helpers/base64url.mdx b/apps/content/docs/helpers/base64url.mdx index 225da86379..d785cca102 100644 --- a/apps/content/docs/helpers/base64url.mdx +++ b/apps/content/docs/helpers/base64url.mdx @@ -1,8 +1,8 @@ --- -title: "Base64Url Helpers" -description: "Encode and decode URL-safe base64url strings with oRPC helpers, useful for web tokens, data serialization, and APIs." +title: 'Base64Url Helpers' +description: 'Encode and decode URL-safe base64url strings with oRPC helpers, useful for web tokens, data serialization, and APIs.' sidebar: - label: "Base64Url" + label: 'Base64Url' --- ## Basic Usage diff --git a/apps/content/docs/helpers/cookie.mdx b/apps/content/docs/helpers/cookie.mdx index af5343026d..a764fbcda7 100644 --- a/apps/content/docs/helpers/cookie.mdx +++ b/apps/content/docs/helpers/cookie.mdx @@ -1,8 +1,8 @@ --- -title: "Cookie Helpers" -description: "Set, read, and delete HTTP cookies from fetch Headers with oRPC cookie helpers, and secure them with signing or encryption." +title: 'Cookie Helpers' +description: 'Set, read, and delete HTTP cookies from fetch Headers with oRPC cookie helpers, and secure them with signing or encryption.' sidebar: - label: "Cookie" + label: 'Cookie' --- ## Basic Usage @@ -15,7 +15,7 @@ const resHeaders = new Headers() setCookie(resHeaders, 'sessionId', 'abc123', { secure: true, - maxAge: 3600 + maxAge: 3600, }) deleteCookie(resHeaders, 'sessionId') @@ -42,7 +42,7 @@ const resHeaders = new Headers() setCookie(resHeaders, 'sessionId', await sign('abc123', secret), { httpOnly: true, secure: true, - maxAge: 3600 + maxAge: 3600, }) const signedSessionId = await unsign(getCookie(reqHeaders, 'sessionId'), secret) diff --git a/apps/content/docs/helpers/encryption.mdx b/apps/content/docs/helpers/encryption.mdx index 894c472de6..aac1745465 100644 --- a/apps/content/docs/helpers/encryption.mdx +++ b/apps/content/docs/helpers/encryption.mdx @@ -1,8 +1,8 @@ --- -title: "Encryption Helpers" -description: "Encrypt and decrypt sensitive data in oRPC using AES-GCM with PBKDF2 key derivation via the encrypt and decrypt helpers." +title: 'Encryption Helpers' +description: 'Encrypt and decrypt sensitive data in oRPC using AES-GCM with PBKDF2 key derivation via the encrypt and decrypt helpers.' sidebar: - label: "Encryption" + label: 'Encryption' --- :::warning diff --git a/apps/content/docs/helpers/form-data.mdx b/apps/content/docs/helpers/form-data.mdx index 926eca7684..2868a88b64 100644 --- a/apps/content/docs/helpers/form-data.mdx +++ b/apps/content/docs/helpers/form-data.mdx @@ -1,8 +1,8 @@ --- -title: "Form Data Helpers" -description: "Parse HTML form data and extract validation error messages with bracket notation support for complex nested structures." +title: 'Form Data Helpers' +description: 'Parse HTML form data and extract validation error messages with bracket notation support for complex nested structures.' sidebar: - label: "Form Data" + label: 'Form Data' --- ## `parseFormData` @@ -41,10 +41,10 @@ const error = { issues: [ { path: ['user', 'email'], - message: 'Invalid email format' - } - ] - } + message: 'Invalid email format', + }, + ], + }, } const emailError = getIssueMessage(error, 'user[email]') @@ -73,8 +73,7 @@ export function ContactForm() { try { const data = parseFormData(form) // Process structured data - } - catch (error) { + } catch (error) { setError(error) } } diff --git a/apps/content/docs/helpers/index.mdx b/apps/content/docs/helpers/index.mdx index ef15ae5e4d..8b873aa8e2 100644 --- a/apps/content/docs/helpers/index.mdx +++ b/apps/content/docs/helpers/index.mdx @@ -1,8 +1,8 @@ --- -title: "Helpers" -description: "Utilities that ship with oRPC for cookies, signing, encryption, base64url, and form data, plus packages for rate limiting, locks, and publish/subscribe." +title: 'Helpers' +description: 'Utilities that ship with oRPC for cookies, signing, encryption, base64url, and form data, plus packages for rate limiting, locks, and publish/subscribe.' seo: - title: "Helpers: Cookies, Signing, Rate Limits, and More" + title: 'Helpers: Cookies, Signing, Rate Limits, and More' sidebar: hidden: true --- diff --git a/apps/content/docs/helpers/lock.mdx b/apps/content/docs/helpers/lock.mdx index eff37e4498..784b107023 100644 --- a/apps/content/docs/helpers/lock.mdx +++ b/apps/content/docs/helpers/lock.mdx @@ -1,8 +1,8 @@ --- -title: "Lock Helpers" -description: "Prevent the same work from running concurrently in oRPC with a unified Locker interface, storage adapters, and procedure middleware." +title: 'Lock Helpers' +description: 'Prevent the same work from running concurrently in oRPC with a unified Locker interface, storage adapters, and procedure middleware.' sidebar: - label: "Lock" + label: 'Lock' --- ## Installation @@ -100,11 +100,7 @@ const procedure = os const locker = new MemoryLocker() -const result = await call( - procedure, - { id: '123' }, - { context: { locker } } -) +const result = await call(procedure, { id: '123' }, { context: { locker } }) ``` :::info[Automatic Deduplication] @@ -283,14 +279,17 @@ Stores locks in Durable Objects, so every Worker instance shares the same locks. ```jsonc title="wrangler.jsonc" { "durable_objects": { - "bindings": [{ "name": "LOCK_DON", "class_name": "LockDO" }] + "bindings": [{ "name": "LOCK_DON", "class_name": "LockDO" }], }, - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["LockDO"] }] + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["LockDO"] }], } ``` ```ts cloudflare -import { experimental_DurableLocker as DurableLocker, experimental_DurableLockObject as DurableLockObject } from '@orpc/cloudflare' +import { + experimental_DurableLocker as DurableLocker, + experimental_DurableLockObject as DurableLockObject, +} from '@orpc/cloudflare' export class LockDO extends DurableLockObject {} diff --git a/apps/content/docs/helpers/publisher.mdx b/apps/content/docs/helpers/publisher.mdx index 65b68df12a..19c800b8e0 100644 --- a/apps/content/docs/helpers/publisher.mdx +++ b/apps/content/docs/helpers/publisher.mdx @@ -1,8 +1,8 @@ --- -title: "Publisher Helpers" -description: "Publish and subscribe to events across storage backends in oRPC, with adapters, dynamic event names, and resume support for missed events." +title: 'Publisher Helpers' +description: 'Publish and subscribe to events across storage backends in oRPC, with adapters, dynamic event names, and resume support for missed events.' sidebar: - label: "Publisher" + label: 'Publisher' --- ## Installation @@ -26,20 +26,17 @@ const publisher = new MemoryPublisher<{ } }>() -const live = os - .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 - } - }) +const live = os.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 + } +}) -const publish = os - .input(z.object({ id: z.string() })) - .handler(async ({ input }) => { - await publisher.publish('something-updated', { id: input.id }) - }) +const publish = os.input(z.object({ id: z.string() })).handler(async ({ input }) => { + await publisher.publish('something-updated', { id: input.id }) +}) ``` :::tip @@ -86,8 +83,8 @@ const publisher = new MemoryPublisher({ * * @default 300000 (5 min) */ - ttl: 300_000 - } + ttl: 300_000, + }, }) ``` @@ -97,7 +94,7 @@ import { RedisPublisher } from '@orpc/publisher/redis' const client = createClient({ url: 'redis://localhost:6379' }) -client.on('error', error => console.error('Redis client error', error)) +client.on('error', (error) => console.error('Redis client error', error)) // RedisPublisher lazily connects to Redis when needed. // You can still call `client.connect()` manually, but it is optional. @@ -147,8 +144,8 @@ const publisher = new RedisPublisher(client, { * * @default 300000 (5 min) */ - ttl: 300_000 - } + ttl: 300_000, + }, }) // Creates a publisher with an extra prefix that shares this client, e.g. per tenant @@ -196,8 +193,8 @@ const publisher = new UpstashPublisher(redis, { * * @default 300000 (5 min) */ - ttl: 300_000 - } + ttl: 300_000, + }, }) // Creates a publisher with an extra prefix that shares this client, e.g. per tenant @@ -256,8 +253,8 @@ const publisher = new BunRedisPublisher(redis, { * * @default 300000 (5 min) */ - ttl: 300_000 - } + ttl: 300_000, + }, }) // Creates a publisher with an extra prefix that shares this client, e.g. per tenant @@ -310,8 +307,8 @@ export class PublisherDO extends DurablePublisherObject { * * @default 'orpc:' */ - schemaPrefix: 'orpc:' - } + schemaPrefix: 'orpc:', + }, }) } } @@ -338,7 +335,7 @@ export default { * * @default ((namespace, event) => namespace.getByName(event)) */ - getStubByName: (namespace, event) => namespace.getByName(event) + getStubByName: (namespace, event) => namespace.getByName(event), }) // Creates a publisher with an extra prefix that shares this namespace, e.g. per tenant @@ -358,7 +355,7 @@ const publisher = new MemoryPublisher({ resume: { enabled: true, // Enable resuming missed events ttl: 5 * 60 * 1000, // How long to retain events, in milliseconds - } + }, }) const iterator = publisher.subscribe('something-updated', { @@ -376,25 +373,22 @@ When resume is enabled, the publisher manages event ids automatically. This mean ```ts import { getEventMeta, withEventMeta } from '@orpc/server' -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 payloads - const id = getEventMeta(payload)?.id - yield withEventMeta({ custom: 'value' }, { id }) - } - }) - -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' }) - ) - }) +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 payloads + const id = getEventMeta(payload)?.id + yield withEventMeta({ custom: 'value' }, { id }) + } +}) + +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' }), + ) +}) ``` ::: @@ -417,9 +411,8 @@ while (true) { console.log(payload) } - } - catch { - await new Promise(resolve => setTimeout(resolve, 1000)) // Wait 1 second before retrying + } catch { + await new Promise((resolve) => setTimeout(resolve, 1000)) // Wait 1 second before retrying } } ``` diff --git a/apps/content/docs/helpers/ratelimit.mdx b/apps/content/docs/helpers/ratelimit.mdx index f8f1462cf9..7321ffa6ef 100644 --- a/apps/content/docs/helpers/ratelimit.mdx +++ b/apps/content/docs/helpers/ratelimit.mdx @@ -1,8 +1,8 @@ --- -title: "Rate Limit Helpers" -description: "Add rate limiting to oRPC with a unified RateLimiter interface, storage adapters, procedure middleware, and a handler plugin for HTTP headers." +title: 'Rate Limit Helpers' +description: 'Add rate limiting to oRPC with a unified RateLimiter interface, storage adapters, procedure middleware, and a handler plugin for HTTP headers.' sidebar: - label: "Rate Limit" + label: 'Rate Limit' --- ## Installation @@ -86,11 +86,7 @@ const ratelimiter = new MemoryRateLimiter({ window: 60000, }) -const result = await call( - procedure, - { email: 'user@example.com' }, - { context: { ratelimiter } } -) +const result = await call(procedure, { email: 'user@example.com' }, { context: { ratelimiter } }) ``` :::info[Automatic Deduplication] @@ -132,9 +128,7 @@ The `RateLimitHandlerPlugin` automatically adds HTTP rate limiting headers (`Rat import { RateLimitHandlerPlugin } from '@orpc/ratelimit' const handler = new RPCHandler(router, { - plugins: [ - new RateLimitHandlerPlugin(), - ], + plugins: [new RateLimitHandlerPlugin()], }) ``` @@ -316,11 +310,11 @@ export default { * * @default '' */ - prefix: '' + prefix: '', }) // Creates a limiter with an extra prefix that shares this binding, e.g. per tenant const tenantLimiter = limiter.prefix('tenant:123:') - } + }, } ``` diff --git a/apps/content/docs/helpers/signing.mdx b/apps/content/docs/helpers/signing.mdx index 75dba90450..1919cab752 100644 --- a/apps/content/docs/helpers/signing.mdx +++ b/apps/content/docs/helpers/signing.mdx @@ -1,8 +1,8 @@ --- -title: "Signing Helpers" -description: "Cryptographically sign and verify data with HMAC-SHA256 using oRPC signing helpers, a faster alternative to encryption." +title: 'Signing Helpers' +description: 'Cryptographically sign and verify data with HMAC-SHA256 using oRPC signing helpers, a faster alternative to encryption.' sidebar: - label: "Signing" + label: 'Signing' --- :::info diff --git a/apps/content/docs/integrations/ai-sdk.mdx b/apps/content/docs/integrations/ai-sdk.mdx index c0c8c55a99..a7fa724957 100644 --- a/apps/content/docs/integrations/ai-sdk.mdx +++ b/apps/content/docs/integrations/ai-sdk.mdx @@ -1,8 +1,8 @@ --- -title: "AI SDK Integration" -description: "Learn how to use oRPC as a transport for AI SDK streams and turn oRPC procedures and contracts into AI SDK tools." +title: 'AI SDK Integration' +description: 'Learn how to use oRPC as a transport for AI SDK streams and turn oRPC procedures and contracts into AI SDK tools.' sidebar: - label: "AI SDK" + label: 'AI SDK' --- :::warning @@ -23,7 +23,7 @@ import { convertToModelMessages, streamText, toUIMessageStream, UIMessage } from import { google } from '@ai-sdk/google' export const chat = os - .input(type<{ chatId: string, messages: UIMessage[] }>()) + .input(type<{ chatId: string; messages: UIMessage[] }>()) .handler(async ({ input }) => { const result = streamText({ model: google('gemini-2.5-flash'), @@ -31,9 +31,7 @@ export const chat = os messages: await convertToModelMessages(input.messages), }) - return streamToAsyncIteratorObject( - toUIMessageStream(result), - ) + return streamToAsyncIteratorObject(toUIMessageStream(result)) }) ``` @@ -51,10 +49,15 @@ export function Example() { const { messages, sendMessage, status } = useChat({ transport: { async sendMessages(options) { - return asyncIteratorToUnproxiedDataStream(await client.chat({ - chatId: options.chatId, - messages: options.messages, - }, { signal: options.abortSignal })) + return asyncIteratorToUnproxiedDataStream( + await client.chat( + { + chatId: options.chatId, + messages: options.messages, + }, + { signal: options.abortSignal }, + ), + ) }, reconnectToStream(options) { throw new Error('Unsupported') @@ -65,7 +68,7 @@ export function Example() { return ( <> - {messages.map(message => ( + {messages.map((message) => (
{message.role === 'user' ? 'User: ' : 'AI: '} {message.parts.map((part, index) => @@ -85,7 +88,7 @@ export function Example() { > setInput(e.target.value)} + onChange={(e) => setInput(e.target.value)} disabled={status !== 'ready'} placeholder="Say something..." /> @@ -116,17 +119,24 @@ Implements a [procedure contract](/docs/contract/procedure) as an [AI SDK Tool]( import { aiSdkTool, implementToolFactory } from '@orpc/ai-sdk' const getWeatherContract = oc - .meta(aiSdkTool({ // Base AI SDK tool options - description: 'Get the weather in a location', - metadata: { source: 'weather-service' } - })) - .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'), - })) + .meta( + aiSdkTool({ + // Base AI SDK tool options + description: 'Get the weather in a location', + metadata: { source: 'weather-service' }, + }), + ) + .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 implementTool = implementToolFactory() @@ -157,17 +167,24 @@ import { os } from '@orpc/server' import { z } from 'zod' const getWeatherProcedure = os - .meta(aiSdkTool({ // Base AI SDK tool options - description: 'Get the weather in a location', - metadata: { source: 'weather-service' } - })) - .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'), - })) + .meta( + aiSdkTool({ + // Base AI SDK tool options + description: 'Get the weather in a location', + metadata: { source: 'weather-service' }, + }), + ) + .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, @@ -192,12 +209,14 @@ import { asyncIteratorObject, os } from '@orpc/server' const deployProcedure = os .input(z.object({ app: z.string() })) - .output(asyncIteratorObject( - z.object({ - status: z.string(), - url: z.string().optional().describe('Available once the deploy finishes'), - }), - )) + .output( + asyncIteratorObject( + z.object({ + status: z.string(), + url: z.string().optional().describe('Available once the deploy finishes'), + }), + ), + ) .handler(async function* ({ input }) { yield { status: 'building' } yield { status: 'uploading' } diff --git a/apps/content/docs/integrations/arktype.mdx b/apps/content/docs/integrations/arktype.mdx index 221d20bbb5..59c097b8d3 100644 --- a/apps/content/docs/integrations/arktype.mdx +++ b/apps/content/docs/integrations/arktype.mdx @@ -1,8 +1,8 @@ --- -title: "ArkType Integration" -description: "Use ArkType types directly in oRPC via Standard Schema, with a dedicated JSON Schema converter for OpenAPI generation and Smart Coercion." +title: 'ArkType Integration' +description: 'Use ArkType types directly in oRPC via Standard Schema, with a dedicated JSON Schema converter for OpenAPI generation and Smart Coercion.' sidebar: - label: "ArkType" + label: 'ArkType' --- :::info diff --git a/apps/content/docs/integrations/better-auth.mdx b/apps/content/docs/integrations/better-auth.mdx index 9b589a56db..10f9651126 100644 --- a/apps/content/docs/integrations/better-auth.mdx +++ b/apps/content/docs/integrations/better-auth.mdx @@ -1,8 +1,8 @@ --- -title: "Better Auth Integration" +title: 'Better Auth Integration' description: "Protect oRPC procedures with typed Better Auth sessions, using your framework's Better Auth integration or the request and response headers plugins." sidebar: - label: "Better Auth" + label: 'Better Auth' --- Use [Better Auth](https://better-auth.com/) with oRPC [middleware](/docs/middleware). No extra package is needed. @@ -16,12 +16,16 @@ In a full-stack framework, such as [Next.js](https://better-auth.com/docs/integr Use the [Request Headers Plugin](/docs/plugins/request-headers) to read the session and the [Response Headers Plugin](/docs/plugins/response-headers) to forward Better Auth's cookies. ```ts -import type { RequestHeadersHandlerPluginContext, ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' +import type { + RequestHeadersHandlerPluginContext, + ResponseHeadersHandlerPluginContext, +} from '@orpc/server/plugins' import { ORPCError, os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { RequestHeadersHandlerPlugin, ResponseHeadersHandlerPlugin } from '@orpc/server/plugins' -interface ServerContext extends RequestHeadersHandlerPluginContext, ResponseHeadersHandlerPluginContext {} +interface ServerContext + extends RequestHeadersHandlerPluginContext, ResponseHeadersHandlerPluginContext {} const base = os.$context() @@ -47,10 +51,7 @@ const router = { } const handler = new RPCHandler(router, { - plugins: [ - new RequestHeadersHandlerPlugin(), - new ResponseHeadersHandlerPlugin(), - ], + plugins: [new RequestHeadersHandlerPlugin(), new ResponseHeadersHandlerPlugin()], }) ``` diff --git a/apps/content/docs/integrations/cloudflare-traces.mdx b/apps/content/docs/integrations/cloudflare-traces.mdx index 208fdfc9e1..72fc903d99 100644 --- a/apps/content/docs/integrations/cloudflare-traces.mdx +++ b/apps/content/docs/integrations/cloudflare-traces.mdx @@ -1,8 +1,8 @@ --- -title: "Cloudflare Workers Traces Integration" -description: "Record oRPC procedure, middleware, and stream spans in Cloudflare Workers Traces through the Workers custom spans API, with no OpenTelemetry SDK." +title: 'Cloudflare Workers Traces Integration' +description: 'Record oRPC procedure, middleware, and stream spans in Cloudflare Workers Traces through the Workers custom spans API, with no OpenTelemetry SDK.' sidebar: - label: "Cloudflare Traces" + label: 'Cloudflare Traces' --- :::warning @@ -23,9 +23,9 @@ Enable traces in your Wrangler configuration, then register `CloudflareTracer` o { "observability": { "traces": { - "enabled": true - } - } + "enabled": true, + }, + }, } ``` diff --git a/apps/content/docs/integrations/effect.mdx b/apps/content/docs/integrations/effect.mdx index 47f48861cf..bf69da53ef 100644 --- a/apps/content/docs/integrations/effect.mdx +++ b/apps/content/docs/integrations/effect.mdx @@ -1,8 +1,8 @@ --- -title: "Effect Integration" -description: "Write effectful oRPC handlers with Effect generators, call oRPC clients as effects, provide services through context, and validate with Effect Schema." +title: 'Effect Integration' +description: 'Write effectful oRPC handlers with Effect generators, call oRPC clients as effects, provide services through context, and validate with Effect Schema.' sidebar: - label: "Effect" + label: 'Effect' --- :::warning @@ -25,11 +25,13 @@ import { os } from '@orpc/server' import { handlerGen } from '@orpc/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 -})) +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 @@ -83,13 +85,13 @@ class Random extends Context.Service< interface ServerContext extends WithEffectContext {} -const procedure = os - .$context() - .handler(handlerGen(function* ({ input, context }) { +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: { @@ -97,8 +99,8 @@ const random = await call(procedure, undefined, { Context.add(Random, { next: Effect.sync(() => Math.random()), }), - ) - } + ), + }, }) ``` @@ -111,15 +113,15 @@ const procedure = os .use(({ context, next }) => { return next({ context: { - 'effect/context': context['effect/context'].pipe( - Context.add(AdditionService, {}), - ) - } + 'effect/context': context['effect/context'].pipe(Context.add(AdditionService, {})), + }, }) }) - .handler(handlerGen(function* ({ input, context }) { - const additionService = yield* AdditionService - })) + .handler( + handlerGen(function* ({ input, context }) { + const additionService = yield* AdditionService + }), + ) ``` ::: @@ -153,20 +155,23 @@ export async function fetch(request: Request) { const { matched, response } = await handler.handle(request, { context: { 'effect/context': Context.empty(), - 'effect/wrap': effect => effect.pipe( - Effect.catchCause((cause) => { - const error = Cause.squash(cause) - - if (error instanceof NotFoundError) { - return Effect.fail(new ORPCError('NOT_FOUND', { - message: `${error.resource} not found`, - cause: error, - })) - } - - return Effect.failCause(cause) - }), - ), + 'effect/wrap': (effect) => + effect.pipe( + Effect.catchCause((cause) => { + const error = Cause.squash(cause) + + if (error instanceof NotFoundError) { + return Effect.fail( + new ORPCError('NOT_FOUND', { + message: `${error.resource} not found`, + cause: error, + }), + ) + } + + return Effect.failCause(cause) + }), + ), }, }) @@ -195,13 +200,15 @@ const procedure = os message: 'The resource you are looking for does not exist', }, }) - .handler(handlerGen(function* ({ errors }) { - if (resourceNotFound) { - return yield* Effect.fail(errors.NOT_FOUND()) - } + .handler( + handlerGen(function* ({ errors }) { + if (resourceNotFound) { + return yield* Effect.fail(errors.NOT_FOUND()) + } - return 'Success' - })) + return 'Success' + }), + ) const [error, result] = await safe(call(procedure)) @@ -218,18 +225,14 @@ Use `catchORPCError` to recover from every `ORPCError` failure in the error chan import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from '@orpc/effect' import { Effect } from 'effect' -const recovered = program.pipe( - catchORPCError(error => Effect.succeed(`caught ${error.code}`)), -) +const recovered = program.pipe(catchORPCError((error) => Effect.succeed(`caught ${error.code}`))) -const fallback = program.pipe( - catchORPCErrorCode('NOT_FOUND', error => Effect.succeed(null)), -) +const fallback = program.pipe(catchORPCErrorCode('NOT_FOUND', (error) => Effect.succeed(null))) const handled = program.pipe( catchORPCErrorCodes({ - NOT_FOUND: error => Effect.succeed(null), - CONFLICT: error => Effect.succeed(error.message), + NOT_FOUND: (error) => Effect.succeed(null), + CONFLICT: (error) => Effect.succeed(error.message), }), ) ``` @@ -256,10 +259,12 @@ import { Clock } from 'effect' const procedure = os .$context<{ auth: boolean }>() - .use(middlewareGen(function* ({ context, next }) { - const startedAt = yield* Clock.currentTimeMillis - return yield* next({ context: { startedAt } }) - })) + .use( + middlewareGen(function* ({ context, next }) { + const startedAt = yield* Clock.currentTimeMillis + return yield* next({ context: { startedAt } }) + }), + ) .handler(({ context }) => context.startedAt) ``` @@ -278,7 +283,7 @@ const program = Effect.gen(function* () { return planet.name }).pipe( catchORPCErrorCodes({ - NOT_FOUND: error => Effect.succeed('unknown'), + NOT_FOUND: (error) => Effect.succeed('unknown'), }), ) ``` @@ -310,9 +315,11 @@ import { Schema } from 'effect' const procedure = os .input(Schema.toStandardSchemaV1(Schema.Struct({ name: Schema.String }))) - .handler(handlerGen(function* ({ input, context }) { - return `Hello ${input.name}!` - })) + .handler( + handlerGen(function* ({ input, context }) { + return `Hello ${input.name}!` + }), + ) ``` ### Schema Extension @@ -330,13 +337,15 @@ const procedure = base }) .input(Schema.Struct({ name: Schema.String })) .output(Schema.Struct({ greeting: Schema.String })) - .handler(handlerGen(function* ({ input, errors }) { - if (input.name === 'admin') { - return yield* Effect.fail(errors.FORBIDDEN({ data: { reason: 'Reserved name' } })) - } + .handler( + handlerGen(function* ({ input, errors }) { + if (input.name === 'admin') { + return yield* Effect.fail(errors.FORBIDDEN({ data: { reason: 'Reserved name' } })) + } - return { greeting: `Hello ${input.name}!` } - })) + return { greeting: `Hello ${input.name}!` } + }), + ) ``` ```ts setup diff --git a/apps/content/docs/integrations/evlog.mdx b/apps/content/docs/integrations/evlog.mdx index b1c562f874..a5a5364c32 100644 --- a/apps/content/docs/integrations/evlog.mdx +++ b/apps/content/docs/integrations/evlog.mdx @@ -1,8 +1,8 @@ --- -title: "Evlog Integration" -description: "Add structured logging to oRPC with the Evlog integration to trace requests, monitor errors, and inspect application behavior." +title: 'Evlog Integration' +description: 'Add structured logging to oRPC with the Evlog integration to trace requests, monitor errors, and inspect application behavior.' sidebar: - label: "Evlog" + label: 'Evlog' --- :::warning @@ -71,18 +71,17 @@ import { createLoggerStorage } from '@orpc/evlog/node' */ 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] +const procedure = os.handler(async () => { + const logger = useLogger() // [!code highlight] - await logger.fork('child-procedure', () => { - const logger = useLogger() // [!code highlight] - }) + logger?.set({ user: { id: 123, name: 'John Doe' } }) // [!code highlight] - return { success: true } + await logger.fork('child-procedure', () => { + const logger = useLogger() // [!code highlight] }) + + return { success: true } +}) ``` ```ts title="handler setup" @@ -106,13 +105,11 @@ import { getLogger, LoggerContext } from '@orpc/evlog' interface ServerContext extends LoggerContext {} // [!code highlight] -const procedure = os - .$context() - .handler(({ context }) => { - const logger = getLogger(context) // [!code highlight] +const procedure = os.$context().handler(({ context }) => { + const logger = getLogger(context) // [!code highlight] - logger?.set({ user: { id: 123, name: 'John Doe' } }) // [!code highlight] + logger?.set({ user: { id: 123, name: 'John Doe' } }) // [!code highlight] - return { success: true } - }) + return { success: true } +}) ``` diff --git a/apps/content/docs/integrations/hibernation.mdx b/apps/content/docs/integrations/hibernation.mdx index 4dcd0976b7..7f464103da 100644 --- a/apps/content/docs/integrations/hibernation.mdx +++ b/apps/content/docs/integrations/hibernation.mdx @@ -1,8 +1,8 @@ --- -title: "Hibernation Integration" +title: 'Hibernation Integration' description: "Learn how oRPC integrates with Hibernation APIs like Cloudflare's WebSocket Hibernation so servers can sleep without dropping connections." sidebar: - label: "Hibernation" + label: 'Hibernation' --- ## Installation @@ -17,9 +17,7 @@ npm install @orpc/hibernation@beta import { HibernationHandlerPlugin } from '@orpc/hibernation' const handler = new RPCHandler(router, { - plugins: [ - new HibernationHandlerPlugin(), - ], + plugins: [new HibernationHandlerPlugin()], }) ``` @@ -63,14 +61,26 @@ The plugin provides `HibernationAsyncIteratorClass` and `encodeHibernationRPCEve const { id } = ws.deserializeAttachment() // yield an event to all clients - ws.send(await encodeHibernationRPCEvent(id, { message: input.message }, { - // override the default RPC serializer if needed - serializer: new RPCSerializer(), - })) + ws.send( + await encodeHibernationRPCEvent( + id, + { message: input.message }, + { + // override the default RPC serializer if needed + serializer: new RPCSerializer(), + }, + ), + ) // return an event and stop the iterator - ws.send(await encodeHibernationRPCEvent(id, { message: input.message }, { event: 'close' })) + ws.send( + await encodeHibernationRPCEvent(id, { message: input.message }, { event: 'close' }), + ) // throw an error and stop the iterator - ws.send(await encodeHibernationRPCEvent(id, new ORPCError('INTERNAL_SERVER_ERROR'), { event: 'error' })) + ws.send( + await encodeHibernationRPCEvent(id, new ORPCError('INTERNAL_SERVER_ERROR'), { + event: 'error', + }), + ) } }) ``` @@ -126,9 +136,7 @@ const handler = new RPCHandler(router, { console.error(error) }), ], - plugins: [ - new HibernationHandlerPlugin(), - ], + plugins: [new HibernationHandlerPlugin()], }) export class ChatRoom extends DurableObject { @@ -165,7 +173,9 @@ 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`) +const websocket = new WebSocket( + `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/chat-room`, +) websocket.addEventListener('error', (event) => { console.error(event) @@ -189,8 +199,10 @@ export function ChatRoom() { const controller = new AbortController() void (async () => { - for await (const message of await chatRoomClient.onMessage(undefined, { signal: controller.signal })) { - setMessages(messages => [...messages, message]) + for await (const message of await chatRoomClient.onMessage(undefined, { + signal: controller.signal, + })) { + setMessages((messages) => [...messages, message]) } })() diff --git a/apps/content/docs/integrations/index.mdx b/apps/content/docs/integrations/index.mdx index 21258ac203..ca441e46b3 100644 --- a/apps/content/docs/integrations/index.mdx +++ b/apps/content/docs/integrations/index.mdx @@ -1,8 +1,8 @@ --- -title: "Integrations" -description: "Use oRPC with the tools around it: Zod, Valibot, ArkType, TanStack Query, SWR, Next.js, NestJS, MCP, the AI SDK, OpenTelemetry, Better Auth, and more." +title: 'Integrations' +description: 'Use oRPC with the tools around it: Zod, Valibot, ArkType, TanStack Query, SWR, Next.js, NestJS, MCP, the AI SDK, OpenTelemetry, Better Auth, and more.' seo: - title: "Integrations: TanStack Query, Zod, MCP, and More" + title: 'Integrations: TanStack Query, Zod, MCP, and More' sidebar: hidden: true --- diff --git a/apps/content/docs/integrations/mcp.mdx b/apps/content/docs/integrations/mcp.mdx index d4edd3b79a..23cd6c5ed7 100644 --- a/apps/content/docs/integrations/mcp.mdx +++ b/apps/content/docs/integrations/mcp.mdx @@ -1,8 +1,8 @@ --- -title: "MCP Integration" -description: "Expose oRPC procedures as Model Context Protocol tools, reusing their OpenAPI metadata, schemas, middleware, and error handling." +title: 'MCP Integration' +description: 'Expose oRPC procedures as Model Context Protocol tools, reusing their OpenAPI metadata, schemas, middleware, and error handling.' sidebar: - label: "MCP" + label: 'MCP' --- :::warning @@ -41,23 +41,25 @@ Connect each server to a transport as described in the [MCP TypeScript SDK](http Tool definitions derive from [OpenAPI metadata](/docs/openapi/specification#metadata): -| Tool field | Derived from | -| ------------- | ---------------------------------------------------------------------------------- | -| `name` | `operationId`, otherwise the router path joined by `_`, such as `planet_list` | -| `title` | `summary` | -| `description` | `description`, otherwise `summary` | +| Tool field | Derived from | +| ------------- | ---------------------------------------------------------------------------------------------------- | +| `name` | `operationId`, otherwise the router path joined by `_`, such as `planet_list` | +| `title` | `summary` | +| `description` | `description`, otherwise `summary` | | `annotations` | `method`: `GET`, `HEAD`, and `QUERY` imply `readOnlyHint`, `PUT` and `DELETE` imply `idempotentHint` | ```ts import { openapi } from '@orpc/openapi' const listPlanets = os - .meta(openapi({ - method: 'GET', - operationId: 'listPlanets', - summary: 'List planets', - description: 'List planets, newest first, optionally limited to a number of results.', - })) + .meta( + openapi({ + method: 'GET', + operationId: 'listPlanets', + summary: 'List planets', + description: 'List planets, newest first, optionally limited to a number of results.', + }), + ) .input(z.object({ limit: z.number().int().min(1).max(100).optional() })) .output(z.array(PlanetSchema)) .handler(async ({ input }) => { @@ -77,13 +79,15 @@ Use `mcp.tool` [metadata](/docs/metadata) to override derived fields or set MCP- import { mcp } from '@orpc/mcp' const deletePlanet = os - .meta(mcp.tool({ - name: 'delete_planet', - title: 'Delete a planet', - description: 'Permanently delete a planet. Ask the user to confirm first.', - annotations: { destructiveHint: true, openWorldHint: false }, - icons: [{ src: 'https://example.com/planet.png', mimeType: 'image/png' }], - })) + .meta( + mcp.tool({ + name: 'delete_planet', + title: 'Delete a planet', + description: 'Permanently delete a planet. Ask the user to confirm first.', + annotations: { destructiveHint: true, openWorldHint: false }, + icons: [{ src: 'https://example.com/planet.png', mimeType: 'image/png' }], + }), + ) .input(z.object({ id: z.number() })) .handler(async ({ input }) => { // ... @@ -142,16 +146,16 @@ Tool arguments are always an object, so a non-object input, such as `z.string()` Outputs are serialized like [OpenAPI responses](/docs/openapi/serializer). Pass your OpenAPI handler's serializer as `serializer` to share [custom types](/docs/openapi/serializer#custom-serializers). -| Output | Tool result | -| --------------------------------------------------- | ----------------------------------------------------------------------------- | -| `undefined` | Empty content | -| String | Text content with the string, and structured content | -| Other values | Text content with the JSON, and [structured content](https://modelcontextprotocol.io/specification/latest/server/tools#structured-content) | -| `Blob` or `File` | Image or audio content by MIME type, otherwise embedded resource content | -| Other values containing a `Blob` or `File` | Like other values, with each file also returned as embedded resource content named by its path, such as `file:///report/q3.pdf` | -| `ReadableStream` | Like a `Blob`, typed by the `content-type` response header, such as one set with the [Response Headers Plugin](/docs/plugins/response-headers) | -| [AsyncIteratorObject](/docs/async-iterator-object) | The last event, mapped as above, with every event reported as [progress](https://modelcontextprotocol.io/specification/latest/basic/utilities/progress) when requested | -| Error | [Tool execution error](#error-handling) | +| Output | Tool result | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `undefined` | Empty content | +| String | Text content with the string, and structured content | +| Other values | Text content with the JSON, and [structured content](https://modelcontextprotocol.io/specification/latest/server/tools#structured-content) | +| `Blob` or `File` | Image or audio content by MIME type, otherwise embedded resource content | +| Other values containing a `Blob` or `File` | Like other values, with each file also returned as embedded resource content named by its path, such as `file:///report/q3.pdf` | +| `ReadableStream` | Like a `Blob`, typed by the `content-type` response header, such as one set with the [Response Headers Plugin](/docs/plugins/response-headers) | +| [AsyncIteratorObject](/docs/async-iterator-object) | The last event, mapped as above, with every event reported as [progress](https://modelcontextprotocol.io/specification/latest/basic/utilities/progress) when requested | +| Error | [Tool execution error](#error-handling) | A tool advertises an output schema only when every result has structured content matching it, so not for optional outputs or outputs containing a `Blob` or `File`. For an AsyncIteratorObject, its yield schema is advertised, so it must yield at least once. @@ -176,7 +180,7 @@ Other errors become `INTERNAL_SERVER_ERROR` without their message. Use [intercep ```ts await registrar.register(server, { - context: async ctx => ({ user: await findUser(ctx) }), + context: async (ctx) => ({ user: await findUser(ctx) }), }) ``` diff --git a/apps/content/docs/integrations/msw.mdx b/apps/content/docs/integrations/msw.mdx index 9b4b714bba..f0d28ceee6 100644 --- a/apps/content/docs/integrations/msw.mdx +++ b/apps/content/docs/integrations/msw.mdx @@ -1,8 +1,8 @@ --- -title: "MSW Integration" -description: "Mock oRPC procedures at the network level with typed MSW request handlers, reusing the real RPC or OpenAPI runtime for serialization and validation." +title: 'MSW Integration' +description: 'Mock oRPC procedures at the network level with typed MSW request handlers, reusing the real RPC or OpenAPI runtime for serialization and validation.' sidebar: - label: "MSW" + label: 'MSW' --- :::warning @@ -29,7 +29,7 @@ import { RPCHandler } from '@orpc/server/fetch' export const mock = createHTTPUtils(contract, { prefix: '/rpc', - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) ``` @@ -46,11 +46,7 @@ The `.handler` method creates an MSW request handler that resolves a procedure. ```ts import { setupServer } from 'msw/node' -const server = setupServer( - mock.planet.list.handler(({ input }) => [ - { id: 1, name: 'Earth' }, - ]), -) +const server = setupServer(mock.planet.list.handler(({ input }) => [{ id: 1, name: 'Earth' }])) server.listen() ``` @@ -114,16 +110,20 @@ import { ResponseHeadersHandlerPlugin } from '@orpc/server/plugins' const mock = createHTTPUtils(contract, { prefix: '/rpc', - handler: router => new RPCHandler(router, { - plugins: [new ResponseHeadersHandlerPlugin()], - }), + handler: (router) => + new RPCHandler(router, { + plugins: [new ResponseHeadersHandlerPlugin()], + }), }) ``` The `context` option controls the [context](/docs/context) passed to the created handler on each request, and mock handlers receive it as `context`, enabling context-driven behaviors such as the [Response Headers Plugin](/docs/plugins/response-headers). ```ts -import { ResponseHeadersHandlerPlugin, type ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' +import { + ResponseHeadersHandlerPlugin, + type ResponseHeadersHandlerPluginContext, +} from '@orpc/server/plugins' interface MockServerContext extends ResponseHeadersHandlerPluginContext { reqHeaders: Headers @@ -131,9 +131,10 @@ interface MockServerContext extends ResponseHeadersHandlerPluginContext { const mock = createHTTPUtils(contract, { context: (info): MockServerContext => ({ reqHeaders: info.request.headers }), - handler: router => new RPCHandler(router, { - plugins: [new ResponseHeadersHandlerPlugin()], - }), + handler: (router) => + new RPCHandler(router, { + plugins: [new ResponseHeadersHandlerPlugin()], + }), }) const handlers = [ @@ -149,7 +150,7 @@ You can also disable input or output validation of the mocked data: ```ts const mock = createHTTPUtils(contract, { - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), disableInputValidation: true, disableOutputValidation: true, }) diff --git a/apps/content/docs/integrations/nest.mdx b/apps/content/docs/integrations/nest.mdx index e33ceb39e4..d3dc474ac8 100644 --- a/apps/content/docs/integrations/nest.mdx +++ b/apps/content/docs/integrations/nest.mdx @@ -1,8 +1,8 @@ --- -title: "Implement oRPC contract with NestJS" -description: "Learn how to implement oRPC contracts in NestJS with the @orpc/nest package while keeping type safety and OpenAPI compatibility." +title: 'Implement oRPC contract with NestJS' +description: 'Learn how to implement oRPC contracts in NestJS with the @orpc/nest package while keeping type safety and OpenAPI compatibility.' sidebar: - label: "NestJS" + label: 'NestJS' --- ## Installation @@ -33,14 +33,16 @@ Before implementation, define your [contract](/docs/contract/procedure) as usual import { oc } from '@orpc/contract' import { openapi, populateRouterContractOpenAPIPaths } from '@orpc/openapi' -const example = oc.meta(openapi({ - path: '/example' // [!code highlight] -})) +const example = oc.meta( + openapi({ + path: '/example', // [!code highlight] + }), +) // or using the `populateRouterContractOpenAPIPaths` helper to // automatically populate OpenAPI paths for all contracts const contract = populateRouterContractOpenAPIPaths({ - example + example, }) ``` @@ -218,7 +220,7 @@ Most handler plugins also work in NestJS, for example [Request Headers](/docs/pl plugins: [ new RethrowHandlerPlugin({ // Bypass oRPC error handling and let NestJS handle the error instead - filter: error => !(error instanceof ORPCError) + filter: (error) => !(error instanceof ORPCError), }), ], }), diff --git a/apps/content/docs/integrations/next.mdx b/apps/content/docs/integrations/next.mdx index 25ffc01608..15215826cd 100644 --- a/apps/content/docs/integrations/next.mdx +++ b/apps/content/docs/integrations/next.mdx @@ -1,8 +1,8 @@ --- -title: "Next.js Integration" -description: "Use oRPC in Next.js applications with server functions, form actions, and React hooks for optimistic updates and typesafe error handling." +title: 'Next.js Integration' +description: 'Use oRPC in Next.js applications with server functions, form actions, and React hooks for optimistic updates and typesafe error handling.' sidebar: - label: "Next.js" + label: 'Next.js' --- ## Installation @@ -24,10 +24,11 @@ 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 + context: async () => { + // <- provide initial context if needed return { user: { id: '123', name: 'Alice' } } }, - interceptors: [] // <- add interceptors if needed + interceptors: [], // <- add interceptors if needed }) ``` @@ -75,12 +76,10 @@ export default function Page() { if (error) { if (error.defined) { // handle typesafe error - } - else { + } else { // handle unknown error } - } - else { + } else { // handle success case } } @@ -123,7 +122,9 @@ return (
{getIssueMessage(error, 'title')} - +
) ``` @@ -140,14 +141,15 @@ If you reuse the same options across multiple server functions, `createServerFun import { createServerFunctionable } from '@orpc/next' const functionable = createServerFunctionable({ - context: async () => { // <- provide initial context if needed + 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!') + os.handler(async () => 'Hello from oRPC + Next.js!'), ) ``` @@ -161,7 +163,8 @@ Import `@orpc/next/extensions/actionable` from a module that always runs during export const functionableProcedure = base .handler(async () => 'Hello from oRPC + Next.js!') .actionable({ - context: async () => { // <- provide initial context if needed + context: async () => { + // <- provide initial context if needed return { user: { id: '123', name: 'Alice' } } }, }) @@ -187,11 +190,7 @@ This integration also includes React hooks for server functions. `useServerFunct 'use client' import { isDefinedError } from '@orpc/client' -import { - getIssueMessage, - onErrorDeferred, - parseFormData, -} from '@orpc/next' +import { getIssueMessage, onErrorDeferred, parseFormData } from '@orpc/next' import { useServerFunction } from '@orpc/next/hooks' export function MyComponent() { @@ -207,7 +206,7 @@ export function MyComponent() { }) return ( -
execute(parseFormData(form))}> + execute(parseFormData(form))}> {getIssueMessage(error, 'name')} @@ -222,11 +221,7 @@ export function MyComponent() { 'use client' import { useOptimisticServerAction } from '@orpc/next/hooks' -import { - getIssueMessage, - onSuccessDeferred, - parseFormData, -} from '@orpc/next' +import { getIssueMessage, onSuccessDeferred, parseFormData } from '@orpc/next' export function MyComponent() { const [todos, setTodos] = useState([]) @@ -235,7 +230,7 @@ export function MyComponent() { optimisticReducer: (currentState, newTodo) => [...currentState, newTodo], interceptors: [ onSuccessDeferred(({ data }) => { - setTodos(prevTodos => [...prevTodos, data]) + setTodos((prevTodos) => [...prevTodos, data]) }), ], }) @@ -243,11 +238,11 @@ export function MyComponent() { return (
    - {optimisticState.map(todo => ( + {optimisticState.map((todo) => (
  • {todo.todo}
  • ))}
- execute(parseFormData(form))}> + execute(parseFormData(form))}> {getIssueMessage(error, 'todo')} @@ -290,19 +285,17 @@ export default function Page() { import { redirect } from 'next/navigation' -const procedure = os - .input(z.object({ name: z.string() })) - .handler(async ({ input }) => { - // do something - }) +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 - } - ] + }, + ], }) ``` @@ -316,13 +309,14 @@ If you reuse the same options across multiple form actions, `createServerFormFun import { createServerFormFunctionable } from '@orpc/next' const formFunctionable = createServerFormFunctionable({ - context: async () => { // <- provide initial context if needed + 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!') + os.handler(async () => 'Hello from oRPC + Next.js!'), ) ``` diff --git a/apps/content/docs/integrations/opentelemetry.mdx b/apps/content/docs/integrations/opentelemetry.mdx index 282a93974b..20cfa45640 100644 --- a/apps/content/docs/integrations/opentelemetry.mdx +++ b/apps/content/docs/integrations/opentelemetry.mdx @@ -1,8 +1,8 @@ --- -title: "OpenTelemetry Integration" -description: "Add automatic OpenTelemetry instrumentation to oRPC for distributed tracing and performance monitoring across client and server." +title: 'OpenTelemetry Integration' +description: 'Add automatic OpenTelemetry instrumentation to oRPC for distributed tracing and performance monitoring across client and server.' sidebar: - label: "OpenTelemetry" + label: 'OpenTelemetry' --- :::warning diff --git a/apps/content/docs/integrations/pinia-colada.mdx b/apps/content/docs/integrations/pinia-colada.mdx index cc6779b40f..687df6304f 100644 --- a/apps/content/docs/integrations/pinia-colada.mdx +++ b/apps/content/docs/integrations/pinia-colada.mdx @@ -1,8 +1,8 @@ --- -title: "Pinia Colada Integration" -description: "Use oRPC clients with Pinia Colada through utilities for building query and mutation options, keys, and typesafe error handling." +title: 'Pinia Colada Integration' +description: 'Use oRPC clients with Pinia Colada through utilities for building query and mutation options, keys, and typesafe error handling.' sidebar: - label: "Pinia Colada" + label: 'Pinia Colada' --- :::warning @@ -31,11 +31,11 @@ To avoid key conflicts when creating multiple sets of utils, pass a unique `pref ```ts const userORPC = createPiniaColadaUtils(userClient, { - prefix: 'user' + prefix: 'user', }) const postORPC = createPiniaColadaUtils(postClient, { - prefix: 'post' + prefix: 'post', }) ``` @@ -46,11 +46,13 @@ const postORPC = createPiniaColadaUtils(postClient, { Use `.queryOptions` to build query options. It works with `useQuery` and any other API that accepts query options. ```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... -})) +const query = useQuery( + orpc.planet.find.queryOptions({ + input: { id: 123 }, // Specify input if needed + context: { cache: true }, // Provide client context if needed + // additional options... + }), +) ``` :::info @@ -62,15 +64,18 @@ Options accept plain values only. For reactive inputs, pass a callback to `useQu Use `.streamedOptions` to build streamed query options for an [AsyncIteratorObject](/docs/async-iterator-object). The resulting data is an array of chunks, and each new chunk is appended as it arrives. It works with `useQuery` and any other API that accepts query options. ```ts -const query = useQuery(orpc.streamed.streamedOptions({ - input: { id: 123 }, // Specify input if needed - context: { cache: true }, // Provide client context if needed - fnOptions: { // Configure streamed query behavior - refetchMode: 'reset', - maxChunks: 3, - }, - // additional options... -})) +const query = useQuery( + orpc.streamed.streamedOptions({ + input: { id: 123 }, // Specify input if needed + context: { cache: true }, // Provide client context if needed + fnOptions: { + // Configure streamed query behavior + refetchMode: 'reset', + maxChunks: 3, + }, + // additional options... + }), +) ``` :::info @@ -87,11 +92,13 @@ const query = useQuery(orpc.streamed.streamedOptions({ Use `.liveOptions` to build live query options for an [AsyncIteratorObject](/docs/async-iterator-object). The data always reflects the latest chunk, replacing the previous value whenever a new one arrives. It works with `useQuery` and any other API that accepts query options. ```ts -const query = useQuery(orpc.live.liveOptions({ - input: { id: 123 }, // Specify input if needed - context: { cache: true }, // Provide client context if needed - // additional options... -})) +const query = useQuery( + orpc.live.liveOptions({ + input: { id: 123 }, // Specify input if needed + context: { cache: true }, // Provide client context if needed + // additional options... + }), +) ``` ## Infinite Query Options Utility @@ -103,13 +110,15 @@ The `input` option must be a function that receives the page parameter and retur ::: ```ts -const query = useInfiniteQuery(() => orpc.planet.list.infiniteOptions({ - input: (offset: number) => ({ limit: 10, offset }), - context: { cache: true }, // Provide client context if needed - initialPageParam: 0, - getNextPageParam: lastPage => lastPage.nextOffset, - // additional options... -})) +const query = useInfiniteQuery(() => + orpc.planet.list.infiniteOptions({ + input: (offset: number) => ({ limit: 10, offset }), + context: { cache: true }, // Provide client context if needed + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextOffset, + // additional options... + }), +) ``` ## Mutation Options @@ -117,10 +126,12 @@ const query = useInfiniteQuery(() => orpc.planet.list.infiniteOptions({ 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({ - context: { cache: true }, // Provide client context if needed - // additional options... -})) +const mutation = useMutation( + orpc.planet.create.mutationOptions({ + context: { cache: true }, // Provide client context if needed + // additional options... + }), +) mutation.mutate({ name: 'Earth' }) ``` @@ -146,12 +157,12 @@ queryCache.invalidateQueries({ // Invalidate only regular (non-infinite) planet queries queryCache.invalidateQueries({ - key: orpc.planet.key({ type: 'query' }) + key: orpc.planet.key({ type: 'query' }), }) // Invalidate the planet find query with id 123 queryCache.invalidateQueries({ - key: orpc.planet.find.key({ input: { id: 123 } }) + key: orpc.planet.find.key({ input: { id: 123 } }), }) // Update the planet find query with id 123 @@ -183,9 +194,11 @@ Option utilities accept plain values only. For reactive inputs, pass a callback ```ts const id = ref(123) -const query = useQuery(() => orpc.planet.find.queryOptions({ - input: { id: id.value }, -})) +const query = useQuery(() => + orpc.planet.find.queryOptions({ + input: { id: id.value }, + }), +) ``` ## Default Options @@ -197,9 +210,9 @@ const orpc = createPiniaColadaUtils(client, { scoped: { planet: { find: { - queryKey: options => ({ + queryKey: (options) => ({ // Override the auto-generated key for .queryKey and .queryOptions - key: options.key ?? ['planet', 'find', options.input] + key: options.key ?? ['planet', 'find', options.input], }), queryOptions: { staleTime: 60 * 1000, // 1 minute @@ -221,10 +234,12 @@ const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 } })) const mutation = useMutation(orpc.planet.create.mutationOptions()) // User-provided options take precedence -const customQuery = useQuery(orpc.planet.find.queryOptions({ - input: { id: 123 }, - staleTime: 0, // overrides the default staleTime -})) +const customQuery = useQuery( + orpc.planet.find.queryOptions({ + input: { id: 123 }, + staleTime: 0, // overrides the default staleTime + }), +) ``` :::info @@ -256,7 +271,7 @@ const orpc = createPiniaColadaUtils(client, { } return data - } + }, ], }) ``` @@ -271,7 +286,7 @@ Plugins package reusable defaults and interceptors for queries and mutations. ```ts const orpc = createPiniaColadaUtils(client, { - plugins: [] + plugins: [], }) ``` @@ -284,9 +299,8 @@ import { ContractOptionsUtilsPlugin, piniaColada } from '@orpc/pinia-colada' export const contract = { planet: { - find: oc - .input(z.object({ id: z.number() })) - .meta(piniaColada({ + find: oc.input(z.object({ id: z.number() })).meta( + piniaColada({ queryOptions: { staleTime: 60 * 1000, }, @@ -296,7 +310,8 @@ export const contract = { return await next() }, ], - })), + }), + ), }, } @@ -328,9 +343,8 @@ declare module '@pinia/colada' { export const contract = { planet: { find: oc.input(z.object({ id: z.number() })), - update: oc - .input(z.object({ id: z.number(), name: z.string() })) - .meta(piniaColada({ + update: oc.input(z.object({ id: z.number(), name: z.string() })).meta( + piniaColada({ mutationInterceptors: [ async ({ input, next, fnContext }) => { const { utils, queryCache } = fnContext @@ -347,18 +361,17 @@ export const contract = { try { return await next() - } - catch (error) { + } catch (error) { // roll back on error queryCache.setQueryData(queryKey, previous) throw error - } - finally { + } finally { queryCache.invalidateQueries({ key: queryKey }) } }, ], - })), + }), + ), }, } @@ -385,8 +398,7 @@ import { } from '@orpc/pinia-colada' import { RPCLink } from '@orpc/client/fetch' -interface ClientContext extends PiniaColadaOperationContext { -} +interface ClientContext extends PiniaColadaOperationContext {} const GET_OPERATION_TYPE = new Set(['query', 'streamed', 'live', 'infinite']) @@ -410,13 +422,15 @@ Use the built-in `isDefinedError` helper to handle [typesafe errors](/docs/error ```ts import { isDefinedError } from '@orpc/client' -const mutation = useMutation(orpc.planet.create.mutationOptions({ - onError: (error) => { - if (isDefinedError(error)) { - // Handle typesafe errors here - } - } -})) +const mutation = useMutation( + orpc.planet.create.mutationOptions({ + onError: (error) => { + if (isDefinedError(error)) { + // Handle typesafe errors here + } + }, + }), +) mutation.mutate({ name: 'Earth' }) diff --git a/apps/content/docs/integrations/pino.mdx b/apps/content/docs/integrations/pino.mdx index be0ac07c83..e216b42916 100644 --- a/apps/content/docs/integrations/pino.mdx +++ b/apps/content/docs/integrations/pino.mdx @@ -1,8 +1,8 @@ --- -title: "Pino Integration" -description: "Add structured logging to oRPC with the Pino integration to track requests, monitor errors, and gain insight into application behavior." +title: 'Pino Integration' +description: 'Add structured logging to oRPC with the Pino integration to track requests, monitor errors, and gain insight into application behavior.' sidebar: - label: "Pino" + label: 'Pino' --- :::warning @@ -78,16 +78,14 @@ import { getLogger, LoggerContext } from '@orpc/pino' interface ServerContext extends LoggerContext {} // [!code highlight] -const procedure = os - .$context() - .handler(({ context }) => { - const logger = getLogger(context) // [!code highlight] +const procedure = os.$context().handler(({ context }) => { + const logger = getLogger(context) // [!code highlight] - logger?.info('Processing request') - logger?.debug({ userId: 123 }, 'User data') + logger?.info('Processing request') + logger?.debug({ userId: 123 }, 'User data') - return { success: true } - }) + return { success: true } +}) ``` ## Providing Custom Logger per Request @@ -95,11 +93,7 @@ const procedure = os You can provide a custom logger instance for specific requests by passing it through the context. This is especially useful when integrating with [pino-http](https://github.com/pinojs/pino-http) for enhanced HTTP logging: ```ts -import { - LOGGER_CONTEXT_SYMBOL, - LoggerContext, - PinoHandlerPlugin -} from '@orpc/pino' +import { LOGGER_CONTEXT_SYMBOL, LoggerContext, PinoHandlerPlugin } from '@orpc/pino' const logger = pino() const httpLogger = pinoHttp({ logger }) @@ -107,7 +101,7 @@ const httpLogger = pinoHttp({ logger }) interface ServerContext extends LoggerContext {} // [!code highlight] const router = { - ping: os.$context().handler(() => 'pong') + ping: os.$context().handler(() => 'pong'), } const handler = new RPCHandler(router, { diff --git a/apps/content/docs/integrations/standard-schema.mdx b/apps/content/docs/integrations/standard-schema.mdx index a1d7088879..d127133355 100644 --- a/apps/content/docs/integrations/standard-schema.mdx +++ b/apps/content/docs/integrations/standard-schema.mdx @@ -1,8 +1,8 @@ --- -title: "Standard Schema Integration" -description: "Use Zod, Valibot, ArkType, or any Standard Schema library with oRPC, plus automatic JSON Schema conversion via Standard JSON Schema." +title: 'Standard Schema Integration' +description: 'Use Zod, Valibot, ArkType, or any Standard Schema library with oRPC, plus automatic JSON Schema conversion via Standard JSON Schema.' sidebar: - label: "Standard Schema" + label: 'Standard Schema' --- oRPC natively supports any library that implements the [Standard Schema](https://standardschema.dev/) specification, such as [Zod](/docs/integrations/zod), [Valibot](/docs/integrations/valibot), [ArkType](/docs/integrations/arktype), and [many more](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec). Use them directly in `.input`, `.output`, and `.errors` without any extra setup. @@ -11,9 +11,7 @@ oRPC natively supports any library that implements the [Standard Schema](https:/ import * as z from 'zod' import * as v from 'valibot' -const example = os - .input(z.object({ name: z.string() })) - .output(v.object({ name: v.string() })) +const example = os.input(z.object({ name: z.string() })).output(v.object({ name: v.string() })) ``` ## Standard JSON Schema @@ -29,7 +27,7 @@ import type { AnySchema, JsonSchema, JsonSchemaConverter, - JsonSchemaConverterDirection + JsonSchemaConverterDirection, } from '@orpc/json-schema' import { toJsonSchema } from '@valibot/to-json-schema' @@ -40,7 +38,7 @@ class MyCustomConverter implements JsonSchemaConverter { convert( schema: AnySchema | undefined, - direction: JsonSchemaConverterDirection + direction: JsonSchemaConverterDirection, ): [jsonSchema: JsonSchema, optional: boolean] { // In most cases, treating the schema as required is acceptable. return [toJsonSchema(schema as any), false] as any diff --git a/apps/content/docs/integrations/swr.mdx b/apps/content/docs/integrations/swr.mdx index 350ab30258..e75db8ab9b 100644 --- a/apps/content/docs/integrations/swr.mdx +++ b/apps/content/docs/integrations/swr.mdx @@ -1,8 +1,8 @@ --- -title: "SWR Integration" -description: "Use oRPC with SWR through lightweight key, fetcher, subscriber, and mutator helpers for data fetching, subscriptions, and mutations in React." +title: 'SWR Integration' +description: 'Use oRPC with SWR through lightweight key, fetcher, subscriber, and mutator helpers for data fetching, subscriptions, and mutations in React.' sidebar: - label: "SWR" + label: 'SWR' --- :::warning @@ -33,11 +33,11 @@ You can avoid key conflicts by passing a unique prefix when creating your utils: ```ts const userORPC = createSWRUtils(userClient, { - prefix: 'user' + prefix: 'user', }) const postORPC = createSWRUtils(postClient, { - prefix: 'post' + prefix: 'post', }) ``` @@ -139,13 +139,9 @@ const planet = await orpc.planet.find.call({ id: 123 }) When clients are invoked through the SWR integration, an **operation context** is automatically added to the [client context](/docs/rpc/link#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/swr' +import { SWR_OPERATION_CONTEXT_SYMBOL, SWROperationContext } from '@orpc/swr' -interface ClientContext extends SWROperationContext { -} +interface ClientContext extends SWROperationContext {} const GET_OPERATION_TYPE = new Set(['fetcher', 'subscriber', 'liveSubscriber']) diff --git a/apps/content/docs/integrations/tanstack-ai.mdx b/apps/content/docs/integrations/tanstack-ai.mdx index 876a7001e0..1b54324adc 100644 --- a/apps/content/docs/integrations/tanstack-ai.mdx +++ b/apps/content/docs/integrations/tanstack-ai.mdx @@ -1,8 +1,8 @@ --- -title: "TanStack AI Integration" -description: "Learn how to use oRPC as a transport for TanStack AI chat streams, through the oRPC client or as a plain Server-Sent Events endpoint." +title: 'TanStack AI Integration' +description: 'Learn how to use oRPC as a transport for TanStack AI chat streams, through the oRPC client or as a plain Server-Sent Events endpoint.' sidebar: - label: "TanStack AI" + label: 'TanStack AI' --- :::info @@ -22,7 +22,7 @@ import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' export const streamChat = os - .input(type<{ messages: UIMessage[], threadId: string, runId: string }>()) + .input(type<{ messages: UIMessage[]; threadId: string; runId: string }>()) .handler(({ input, signal }) => { const abortController = new AbortController() signal?.addEventListener('abort', () => abortController.abort(), { once: true }) @@ -61,7 +61,7 @@ export function Example() { return ( <> - {messages.map(message => ( + {messages.map((message) => (
{message.role === 'user' ? 'User: ' : 'AI: '} {message.parts.map((part, index) => @@ -81,7 +81,7 @@ export function Example() { > setInput(e.target.value)} + onChange={(e) => setInput(e.target.value)} disabled={isLoading} placeholder="Say something..." /> @@ -100,13 +100,15 @@ With [OpenAPIHandler](/docs/openapi/handler), TanStack AI's built-in `fetchServe ```ts export const streamChat = os .meta(openapi({ method: 'POST', path: '/chat' })) - .input(type<{ messages: UIMessage[], threadId: string, runId: string }>()) - .handler(({ input }) => chat({ - adapter: openaiText('gpt-5.5'), - messages: input.messages, - threadId: input.threadId, - runId: input.runId, - })) + .input(type<{ messages: UIMessage[]; threadId: string; runId: string }>()) + .handler(({ input }) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: input.messages, + threadId: input.threadId, + runId: input.runId, + }), + ) const { messages, sendMessage, isLoading } = useChat({ threadId: 'chat-1', diff --git a/apps/content/docs/integrations/tanstack-query.mdx b/apps/content/docs/integrations/tanstack-query.mdx index df18d56056..0518a1123f 100644 --- a/apps/content/docs/integrations/tanstack-query.mdx +++ b/apps/content/docs/integrations/tanstack-query.mdx @@ -1,8 +1,8 @@ --- -title: "TanStack Query Integration" -description: "Integrate oRPC with TanStack Query using utilities for query and mutation options, keys, infinite and streamed queries, and typesafe errors." +title: 'TanStack Query Integration' +description: 'Integrate oRPC with TanStack Query using utilities for query and mutation options, keys, infinite and streamed queries, and typesafe errors.' sidebar: - label: "Tanstack Query" + label: 'Tanstack Query' --- :::warning @@ -48,11 +48,11 @@ To avoid key conflicts when creating multiple sets of utils, pass a unique `pref ```ts const userORPC = createTanstackQueryUtils(userClient, { - prefix: 'user' + prefix: 'user', }) const postORPC = createTanstackQueryUtils(postClient, { - prefix: 'post' + prefix: 'post', }) ``` @@ -63,11 +63,13 @@ const postORPC = createTanstackQueryUtils(postClient, { 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({ - input: { id: 123 }, // Specify input if needed - context: { cache: true }, // Provide client context if needed - // additional options... -})) +const query = useQuery( + orpc.planet.find.queryOptions({ + input: { id: 123 }, // Specify input if needed + context: { cache: true }, // Provide client context if needed + // additional options... + }), +) ``` ## Streamed Query Options @@ -77,16 +79,19 @@ Use `.streamedOptions` to build streamed query options for an [AsyncIteratorObje It works with `useQuery`, `useSuspenseQuery`, and `prefetchQuery`, and any other API that accepts query options. ```ts -const query = useQuery(orpc.streamed.streamedOptions({ - input: { id: 123 }, // Specify input if needed - context: { cache: true }, // Provide client context if needed - queryFnOptions: { // Configure streamed query behavior - refetchMode: 'reset', - maxChunks: 3, - }, - retry: true, // Infinite retry for more reliable streaming - // additional options... -})) +const query = useQuery( + orpc.streamed.streamedOptions({ + input: { id: 123 }, // Specify input if needed + context: { cache: true }, // Provide client context if needed + queryFnOptions: { + // Configure streamed query behavior + refetchMode: 'reset', + maxChunks: 3, + }, + retry: true, // Infinite retry for more reliable streaming + // additional options... + }), +) ``` :::info @@ -105,12 +110,14 @@ Use `.liveOptions` to build live query options for an [AsyncIteratorObject](/doc It works with `useQuery`, `useSuspenseQuery`, and `prefetchQuery`, and any other API that accepts query options. ```ts -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 - // additional options... -})) +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 + // additional options... + }), +) ``` ## Infinite Query Options @@ -122,13 +129,15 @@ The `input` option must be a function that receives the page parameter and retur ::: ```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... -})) +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 @@ -136,10 +145,12 @@ const query = useInfiniteQuery(orpc.planet.list.infiniteOptions({ 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({ - context: { cache: true }, // Provide client context if needed - // additional options... -})) +const mutation = useMutation( + orpc.planet.create.mutationOptions({ + context: { cache: true }, // Provide client context if needed + // additional options... + }), +) mutation.mutate({ name: 'Earth' }) ``` @@ -165,12 +176,12 @@ queryClient.invalidateQueries({ // Invalidate only regular (non-infinite) planet queries queryClient.invalidateQueries({ - queryKey: orpc.planet.key({ type: 'query' }) + queryKey: orpc.planet.key({ type: 'query' }), }) // Invalidate the planet find query with id 123 queryClient.invalidateQueries({ - queryKey: orpc.planet.find.key({ input: { id: 123 } }) + queryKey: orpc.planet.find.key({ input: { id: 123 } }), }) // Update the planet find query with id 123 @@ -202,19 +213,21 @@ In reactive libraries like Vue or Solid, TanStack Query supports passing compute ```ts title="Options as Function" -const query = useQuery( - () => orpc.planet.find.queryOptions({ +const query = useQuery(() => + orpc.planet.find.queryOptions({ input: { id: id() }, - }) + }), ) ``` ```ts title="Computed Options" -const query = useQuery(computed( - () => orpc.planet.find.queryOptions({ - input: { id: id.value }, - }) -)) +const query = useQuery( + computed(() => + orpc.planet.find.queryOptions({ + input: { id: id.value }, + }), + ), +) ``` @@ -228,9 +241,9 @@ const orpc = createTanstackQueryUtils(client, { scoped: { planet: { find: { - queryKey: options => ({ + queryKey: (options) => ({ // Override the auto-generated query key for .queryKey and .queryOptions - queryKey: options.queryKey ?? ['planet', 'find', options.input] + queryKey: options.queryKey ?? ['planet', 'find', options.input], }), queryOptions: { staleTime: 60 * 1000, // 1 minute @@ -238,7 +251,7 @@ const orpc = createTanstackQueryUtils(client, { }, }, list: { - infiniteOptions: options => ({ + infiniteOptions: (options) => ({ ...options, staleTime: 30 * 1000, // override takes priority }), @@ -259,10 +272,12 @@ const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 } })) const mutation = useMutation(orpc.planet.create.mutationOptions()) // User-provided options take precedence -const customQuery = useQuery(orpc.planet.find.queryOptions({ - input: { id: 123 }, - staleTime: 0, // overrides the default staleTime -})) +const customQuery = useQuery( + orpc.planet.find.queryOptions({ + input: { id: 123 }, + staleTime: 0, // overrides the default staleTime + }), +) ``` :::info @@ -294,7 +309,7 @@ const orpc = createTanstackQueryUtils(client, { } return data - } + }, ], scoped: { planet: { @@ -322,7 +337,7 @@ Plugins package reusable defaults and interceptors for queries and mutations. ```ts const orpc = createTanstackQueryUtils(client, { - plugins: [] + plugins: [], }) ``` @@ -335,9 +350,8 @@ import { ContractOptionsUtilsPlugin, tanstackQuery } from '@orpc/tanstack-query' export const contract = { planet: { - find: oc - .input(z.object({ id: z.number() })) - .meta(tanstackQuery({ + find: oc.input(z.object({ id: z.number() })).meta( + tanstackQuery({ queryOptions: { staleTime: 60 * 1000, }, @@ -347,7 +361,8 @@ export const contract = { return await next() }, ], - })), + }), + ), }, } @@ -379,9 +394,8 @@ declare module '@tanstack/react-query' { export const contract = { planet: { find: oc.input(z.object({ id: z.number() })), - update: oc - .input(z.object({ id: z.number(), name: z.string() })) - .meta(tanstackQuery({ + update: oc.input(z.object({ id: z.number(), name: z.string() })).meta( + tanstackQuery({ mutationInterceptors: [ async ({ input, next, fnContext }) => { const utils = fnContext.meta?.utils @@ -398,18 +412,17 @@ export const contract = { try { return await next() - } - catch (error) { + } catch (error) { // roll back on error fnContext.client.setQueryData(queryKey, previous) throw error - } - finally { + } finally { fnContext.client.invalidateQueries({ queryKey }) } }, ], - })), + }), + ), }, } @@ -430,12 +443,14 @@ const queryClient = new QueryClient({ 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 } }], - // additional options... -})) +const query = useQuery( + orpc.planet.find.queryOptions({ + context: { cache: true }, + // manually include context in the query key + queryKey: [['planet', 'find'], { context: { cache: true } }], + // additional options... + }), +) ``` ::: @@ -450,8 +465,7 @@ import { TanstackQueryOperationContext, } from '@orpc/tanstack-query' -interface ClientContext extends TanstackQueryOperationContext { -} +interface ClientContext extends TanstackQueryOperationContext {} const GET_OPERATION_TYPE = new Set(['query', 'streamed', 'live', 'infinite']) @@ -475,13 +489,15 @@ Use the built-in `isDefinedError` helper to handle [typesafe errors](/docs/error ```ts import { isDefinedError } from '@orpc/client' -const mutation = useMutation(orpc.planet.create.mutationOptions({ - onError: (error) => { - if (isDefinedError(error)) { - // Handle typesafe errors here - } - } -})) +const mutation = useMutation( + orpc.planet.create.mutationOptions({ + onError: (error) => { + if (isDefinedError(error)) { + // Handle typesafe errors here + } + }, + }), +) mutation.mutate({ name: 'Earth' }) @@ -498,7 +514,7 @@ The [skipToken symbol](https://tanstack.com/query/latest/docs/framework/react/gu const query = useQuery( orpc.planet.list.queryOptions({ input: search ? { search } : skipToken, // [!code highlight] - }) + }), ) const query = useInfiniteQuery( @@ -507,8 +523,8 @@ const query = useInfiniteQuery( ? (offset: number | undefined) => ({ limit: 10, offset, search }) // [!code highlight] : skipToken, // [!code highlight] initialPageParam: undefined, - getNextPageParam: lastPage => lastPage.nextPageParam, - }) + getNextPageParam: (lastPage) => lastPage.nextPageParam, + }), ) ``` diff --git a/apps/content/docs/integrations/trpc.mdx b/apps/content/docs/integrations/trpc.mdx index c66b08747e..90454a39c4 100644 --- a/apps/content/docs/integrations/trpc.mdx +++ b/apps/content/docs/integrations/trpc.mdx @@ -1,8 +1,8 @@ --- -title: "tRPC Integration" -description: "Learn how to integrate tRPC with oRPC by converting tRPC routers into oRPC routers and bridging metadata between the two." +title: 'tRPC Integration' +description: 'Learn how to integrate tRPC with oRPC by converting tRPC routers into oRPC routers and bridging metadata between the two.' sidebar: - label: "tRPC" + label: 'tRPC' --- ## Installation @@ -33,12 +33,11 @@ const handler = new OpenAPIHandler(orpcRouter, { async ({ next }) => { try { return await next() - } - catch (error) { + } catch (error) { if ( - error instanceof ORPCError - && error.cause instanceof TRPCError - && error.cause.cause instanceof z.ZodError + error instanceof ORPCError && + error.cause instanceof TRPCError && + error.cause.cause instanceof z.ZodError ) { throw new ORPCError('UNPROCESSABLE_CONTENT', { message: z.prettifyError(error.cause.cause), @@ -73,7 +72,8 @@ const example = t.procedure const merged = t.procedure .meta({ - ...toTRPCMeta( // [!code highlight] + ...toTRPCMeta( + // [!code highlight] openapi({ path: '/hello' }), // [!code highlight] openapi({ method: 'POST' }), // [!code highlight] ), // [!code highlight] diff --git a/apps/content/docs/integrations/valibot.mdx b/apps/content/docs/integrations/valibot.mdx index 9d52123c08..0c350085e6 100644 --- a/apps/content/docs/integrations/valibot.mdx +++ b/apps/content/docs/integrations/valibot.mdx @@ -1,8 +1,8 @@ --- -title: "Valibot Integration" -description: "Use Valibot schemas directly in oRPC via Standard Schema, with a dedicated JSON Schema converter for OpenAPI generation and Smart Coercion." +title: 'Valibot Integration' +description: 'Use Valibot schemas directly in oRPC via Standard Schema, with a dedicated JSON Schema converter for OpenAPI generation and Smart Coercion.' sidebar: - label: "Valibot" + label: 'Valibot' --- :::info diff --git a/apps/content/docs/integrations/zod.mdx b/apps/content/docs/integrations/zod.mdx index c54373faed..c6b6d42b65 100644 --- a/apps/content/docs/integrations/zod.mdx +++ b/apps/content/docs/integrations/zod.mdx @@ -1,8 +1,8 @@ --- -title: "Zod Integration" -description: "Use Zod schemas directly in oRPC via Standard Schema, with a dedicated JSON Schema converter and registries for customizing generated schemas." +title: 'Zod Integration' +description: 'Use Zod schemas directly in oRPC via Standard Schema, with a dedicated JSON Schema converter and registries for customizing generated schemas.' sidebar: - label: "Zod" + label: 'Zod' --- :::warning @@ -48,10 +48,12 @@ A common pattern is defining reusable schemas with `id` metadata. The converter ```ts import * as z from 'zod' -const PlanetSchema = z.object({ - id: z.string(), - name: z.string(), -}).meta({ id: 'Planet' }) +const PlanetSchema = z + .object({ + id: z.string(), + name: z.string(), + }) + .meta({ id: 'Planet' }) ``` ### Customizing Generated JSON Schemas @@ -68,7 +70,7 @@ import * as z from 'zod' const user = z.object({ name: z.string(), - age: z.string().transform(v => Number(v)), + age: z.string().transform((v) => Number(v)), }) JSON_SCHEMA_REGISTRY.add(user, { diff --git a/apps/content/docs/metadata.mdx b/apps/content/docs/metadata.mdx index 3ca6db8800..b88ac0a90b 100644 --- a/apps/content/docs/metadata.mdx +++ b/apps/content/docs/metadata.mdx @@ -1,6 +1,6 @@ --- -title: "Metadata" -description: "Attach metadata to oRPC procedures with defineMeta or a custom MetaPlugin so middleware, plugins, and tooling can read it to control behavior." +title: 'Metadata' +description: 'Attach metadata to oRPC procedures with defineMeta or a custom MetaPlugin so middleware, plugins, and tooling can read it to control behavior.' --- ## Quickly Define Meta @@ -16,13 +16,15 @@ import { defineMeta } from '@orpc/server' type CacheMeta = boolean -const [cacheMeta, getCacheMeta] = defineMeta( // [!code highlight] +const [cacheMeta, getCacheMeta] = defineMeta( + // [!code highlight] 'cache', // [!code highlight] (incoming: CacheMeta, current) => incoming, // [!code highlight] ) // [!code highlight] const base = os.use(async ({ procedure, next, path }, input, done) => { - if (getCacheMeta(procedure) !== true) { // [!code highlight] + if (getCacheMeta(procedure) !== true) { + // [!code highlight] return next() } @@ -55,7 +57,7 @@ If `defineMeta` is not flexible enough, define a plugin directly with `MetaPlugi ```ts twoslash import { os } from '@orpc/server' -import z from 'zod' +import * as z from 'zod' // ---cut--- import type { AnySchema, @@ -69,7 +71,7 @@ import type { interface ExampleMeta< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, - TErrorMap extends ErrorMap + TErrorMap extends ErrorMap, > { inputExamples?: InferSchemaInput[] outputExamples?: InferSchemaOutput[] @@ -78,7 +80,7 @@ interface ExampleMeta< interface ExampleMetaPlugin< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, - TErrorMap extends ErrorMap + TErrorMap extends ErrorMap, > extends MetaPlugin { name: 'example' } @@ -88,37 +90,40 @@ function exampleMeta< TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, >( - incoming: ExampleMeta + incoming: ExampleMeta, ): ExampleMetaPlugin { return { name: 'example', apply(meta) { - const current = meta.example as ExampleMeta | undefined + const current = meta.example as + ExampleMeta | undefined return { ...meta, example: { ...current, ...incoming, - } + }, } }, } } -function getExampleMeta( - procedureOrLazy: { '~orpc': { meta: Meta } } -): ExampleMeta | undefined { +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 - })) + .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.mdx b/apps/content/docs/middleware.mdx index 6dd9fb305c..40ca33f942 100644 --- a/apps/content/docs/middleware.mdx +++ b/apps/content/docs/middleware.mdx @@ -1,6 +1,6 @@ --- -title: "Middleware" -description: "Run code before and after oRPC handlers with composable middleware that can inject context, guard access, and modify input or output." +title: 'Middleware' +description: 'Run code before and after oRPC handlers with composable middleware that can inject context, guard access, and modify input or output.' --- ## Overview @@ -16,20 +16,20 @@ const example = os .$context<{ something?: string }>() // <- define initial context .meta(someMeta) // <- attach metadata .errors({ RATE_LIMITED: {} }) // <- attach errors - .middleware(async ({ context, next, errors }) => { // <- middleware logic + .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' } - } + context: { + // <- Inject additional context + user: { id: 1, name: 'John' }, + }, }) - } - catch (error) { + } catch (error) { console.error(error) throw error - } - finally { + } finally { // Cleanup logic after execution } }) @@ -46,9 +46,11 @@ Use `.meta` to attach [metadata](/docs/metadata). Every procedure that uses the ```ts const requireAuth = os - .meta(openapi({ - spec: current => ({ ...current, security: [{ bearerAuth: [] }] }), - })) + .meta( + openapi({ + spec: (current) => ({ ...current, security: [{ bearerAuth: [] }] }), + }), + ) .middleware(async ({ next }) => { // check auth... return next() @@ -80,19 +82,20 @@ const setting = os .use(async ({ context, next }) => { return next({ context: { - auth: await auth() // <- inject auth - } + 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 (now guaranteed to be non-null) - } + auth: context.auth, // <- override auth (now guaranteed to be non-null) + }, }) }) .handler(async ({ context }) => { @@ -123,7 +126,7 @@ const ping = os const pong = os .input(z.object({ id: z.number() })) - .use(canUpdate.adaptInput(input => input.id)) // <- adapt input to match middleware's expected shape + .use(canUpdate.adaptInput((input) => input.id)) // <- adapt input to match middleware's expected shape .handler(async ({ input }) => { // Handler logic }) @@ -201,9 +204,7 @@ const example = os Multiple middleware functions can be combined using `.use`. ```ts -const mergedMiddleware = aMiddleware - .use(async ({ next }) => next()) - .use(anotherMiddleware) +const mergedMiddleware = aMiddleware.use(async ({ next }) => next()).use(anotherMiddleware) ``` :::info diff --git a/apps/content/docs/migrations/from-trpc.mdx b/apps/content/docs/migrations/from-trpc.mdx index 33e407a3c7..2a756314c0 100644 --- a/apps/content/docs/migrations/from-trpc.mdx +++ b/apps/content/docs/migrations/from-trpc.mdx @@ -1,6 +1,6 @@ --- -title: "Migrating from tRPC" -description: "Migrate an existing tRPC app to oRPC step by step. Most tRPC concepts map directly, so routers, procedures, and middleware feel familiar." +title: 'Migrating from tRPC' +description: 'Migrate an existing tRPC app to oRPC step by step. Most tRPC concepts map directly, so routers, procedures, and middleware feel familiar.' --- :::info @@ -85,8 +85,7 @@ const timingMiddleware = o.middleware(async ({ next, path }) => { try { return await next() - } - finally { + } finally { console.log(`[oRPC] ${path} took ${Date.now() - start}ms to execute`) } }) @@ -100,8 +99,8 @@ export const protectedProcedure = publicProcedure.use(({ context, next }) => { return next({ context: { - session: { ...context.session, user: context.session.user } - } + session: { ...context.session, user: context.session.user }, + }, }) }) ``` @@ -138,19 +137,17 @@ const timingMiddleware = t.middleware(async ({ next, path }) => { export const publicProcedure = t.procedure.use(timingMiddleware) -export const protectedProcedure = t.procedure - .use(timingMiddleware) - .use(({ ctx, next }) => { - if (!ctx.session?.user) { - throw new TRPCError({ code: 'UNAUTHORIZED' }) - } - - return next({ - ctx: { - session: { ...ctx.session, user: ctx.session.user }, - }, - }) +export const protectedProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: 'UNAUTHORIZED' }) + } + + return next({ + ctx: { + session: { ...ctx.session, user: ctx.session.user }, + }, }) +}) ``` @@ -177,17 +174,19 @@ export const planetRouter = { { name: 'Earth', distanceFromSun: 149.6, - } + }, ], nextCursor: input.cursor + 1, } }), create: protectedProcedure - .input(z.object({ - name: z.string().min(1), - distanceFromSun: z.number().positive() - })) + .input( + z.object({ + name: z.string().min(1), + distanceFromSun: z.number().positive(), + }), + ) .handler(async ({ context, input }) => { // Logic here }), @@ -206,17 +205,19 @@ export const planetRouter = createTRPCRouter({ { name: 'Earth', distanceFromSun: 149.6, - } + }, ], nextCursor: input.cursor + 1, } }), create: protectedProcedure - .input(z.object({ - name: z.string().min(1), - distanceFromSun: z.number().positive() - })) + .input( + z.object({ + name: z.string().min(1), + distanceFromSun: z.number().positive(), + }), + ) .mutation(async ({ ctx, input }) => { // Logic here }), @@ -267,7 +268,7 @@ Error handling is similar, but `ORPCError` takes the error code as its first arg throw new ORPCError('BAD_REQUEST', { message: 'Invalid input', data: 'some data', - cause: validationError + cause: validationError, }) ``` @@ -275,7 +276,7 @@ throw new ORPCError('BAD_REQUEST', { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid input', - cause: validationError + cause: validationError, }) ``` @@ -299,19 +300,18 @@ const handler = new RPCHandler(appRouter, { async ({ next, path }) => { try { return await next() - } - catch (error) { + } catch (error) { console.error(`❌ oRPC failed on ${path.join('.')}: `, error) throw error } - } - ] + }, + ], }) async function handleRequest(request: Request) { const { response } = await handler.handle(request, { prefix: '/api/orpc', - context: await createORPCContext({ headers: request.headers }) + context: await createORPCContext({ headers: request.headers }), }) return response ?? new Response('Not found', { status: 404 }) @@ -331,10 +331,8 @@ function handler(req: Request) { router: appRouter, createContext: () => createTRPCContext({ headers: req.headers }), onError: ({ path, error }) => { - console.error( - `❌ tRPC failed on ${path ?? ''}: ${error.message}` - ) - } + console.error(`❌ tRPC failed on ${path ?? ''}: ${error.message}`) + }, }) } @@ -360,7 +358,7 @@ const link = new RPCLink({ interceptors: [ onError((error) => { console.error(error) - }) + }), ], }) @@ -380,8 +378,8 @@ export const client = createTRPCClient({ httpLink({ url: 'http://localhost:3000/api/trpc', transformer: superjson, - }) - ] + }), + ], }) // ---------------- Usage ---------------- @@ -408,15 +406,19 @@ export const orpc = createTanstackQueryUtils(client) // ---------------- Usage in React Components ---------------- -const query = useQuery(orpc.planet.list.queryOptions({ - input: { cursor: 0 }, -})) +const query = useQuery( + orpc.planet.list.queryOptions({ + input: { cursor: 0 }, + }), +) -const infinite = useInfiniteQuery(orpc.planet.list.infiniteOptions({ - input: (page: number) => ({ cursor: page }), - initialPageParam: 0, - getNextPageParam: lastPage => lastPage.nextCursor, -})) +const infinite = useInfiniteQuery( + orpc.planet.list.infiniteOptions({ + input: (page: number) => ({ cursor: page }), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }), +) const mutation = useMutation(orpc.planet.create.mutationOptions()) ``` @@ -432,13 +434,15 @@ const trpc = useTRPC() const query = useQuery(trpc.planet.list.queryOptions({ cursor: 0 })) -const infinite = useInfiniteQuery(trpc.planet.list.infiniteQueryOptions( - {}, - { - initialCursor: 0, - getNextPageParam: lastPage => lastPage.nextCursor, - } -)) +const infinite = useInfiniteQuery( + trpc.planet.list.infiniteQueryOptions( + {}, + { + initialCursor: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), +) const mutation = useMutation(trpc.planet.create.mutationOptions()) ``` diff --git a/apps/content/docs/migrations/from-v1.mdx b/apps/content/docs/migrations/from-v1.mdx index f671eb7b74..87fc6e4651 100644 --- a/apps/content/docs/migrations/from-v1.mdx +++ b/apps/content/docs/migrations/from-v1.mdx @@ -1,8 +1,8 @@ --- -title: "Migrating from oRPC v1" -description: "Upgrade an oRPC v1 app to v2, with side-by-side v1 and v2 comparisons for every change that needs your attention." +title: 'Migrating from oRPC v1' +description: 'Upgrade an oRPC v1 app to v2, with side-by-side v1 and v2 comparisons for every change that needs your attention.' sidebar: - label: "Migrating from v1" + label: 'Migrating from v1' --- Most of your code keeps working: many v1 names still compile through deprecated aliases, so your editor shows a strike-through hint instead of an error. @@ -60,7 +60,7 @@ If your [OpenAPI Handler](/docs/openapi/handler) endpoints have external consume import { COMMON_ERROR_STATUS_MAP } from '@orpc/openapi' const handler = new OpenAPIHandler(router, { - errorResponseBodyEncoder: error => ({ + errorResponseBodyEncoder: (error) => ({ ...error.toJSON(), status: COMMON_ERROR_STATUS_MAP[error.code] ?? 400, }), @@ -76,9 +76,7 @@ The biggest change: `.route`, `.prefix`, `.tag`, and `.$route` no longer exist o ```ts v2 import { openapi } from '@orpc/openapi' -const listPlanet = os - .meta(openapi({ method: 'GET', path: '/planets' })) - .handler(async () => []) +const listPlanet = os.meta(openapi({ method: 'GET', path: '/planets' })).handler(async () => []) const router = os .meta(openapi({ prefix: '/planets', tags: ['planets'] })) @@ -86,14 +84,9 @@ const router = os ``` ```ts v1 -const listPlanet = os - .route({ method: 'GET', path: '/planets' }) - .handler(async () => []) +const listPlanet = os.route({ method: 'GET', path: '/planets' }).handler(async () => []) -const router = os - .prefix('/planets') - .tag('planets') - .router({ list: listPlanet }) +const router = os.prefix('/planets').tag('planets').router({ list: listPlanet }) ``` @@ -172,9 +165,7 @@ const result = await call(getting, undefined, { context: {} }) ``` ```ts v1 -const getting = os - .handler(async () => 'pong') - .callable({ context: {} }) +const getting = os.handler(async () => 'pong').callable({ context: {} }) const result = await getting() ``` @@ -187,9 +178,7 @@ If you prefer the v1 style, the [`.callable` extension](/docs/client/server-side ```ts import '@orpc/server/extensions/callable' // once at init time -const getting = os - .handler(async () => 'pong') - .callable({ context: {} }) +const getting = os.handler(async () => 'pong').callable({ context: {} }) ``` ::: @@ -208,9 +197,7 @@ import { os } from '@orpc/server' const functionable = createServerFunctionable({ context: {} }) -export const getting = functionable( - os.handler(async () => 'pong'), -) +export const getting = functionable(os.handler(async () => 'pong')) ``` ```ts v1 @@ -218,9 +205,7 @@ export const getting = functionable( import { os } from '@orpc/server' -export const getting = os - .handler(async () => 'pong') - .actionable({ context: {} }) +export const getting = os.handler(async () => 'pong').actionable({ context: {} }) ``` @@ -282,10 +267,7 @@ const base = os.$config({ ```ts v2 import { defineMeta, os } from '@orpc/server' -const [cacheMeta, getCacheMeta] = defineMeta( - 'cache', - (incoming: boolean) => incoming, -) +const [cacheMeta, getCacheMeta] = defineMeta('cache', (incoming: boolean) => incoming) const base = os.use(async ({ procedure, next }) => { if (getCacheMeta(procedure) !== true) { @@ -295,9 +277,7 @@ const base = os.use(async ({ procedure, next }) => { return next() }) -const example = base - .meta(cacheMeta(true)) - .handler(async () => {}) +const example = base.meta(cacheMeta(true)).handler(async () => {}) ``` ```ts v1 @@ -307,19 +287,15 @@ interface ORPCMetadata { cache?: boolean } -const base = os - .$meta({}) - .use(async ({ procedure, next }) => { - if (!procedure['~orpc'].meta.cache) { - return next() - } - // ... +const base = os.$meta({}).use(async ({ procedure, next }) => { + if (!procedure['~orpc'].meta.cache) { return next() - }) + } + // ... + return next() +}) -const example = base - .meta({ cache: true }) - .handler(async () => {}) +const example = base.meta({ cache: true }).handler(async () => {}) ``` @@ -337,7 +313,7 @@ const merged = aMiddleware.use(anotherMiddleware) const example = os .input(z.object({ id: z.number() })) - .use(canUpdate.adaptInput(input => input.id)) + .use(canUpdate.adaptInput((input) => input.id)) .handler(async () => {}) ``` @@ -346,7 +322,7 @@ const merged = aMiddleware.concat(anotherMiddleware) const example = os .input(z.object({ id: z.number() })) - .use(canUpdate, input => input.id) + .use(canUpdate, (input) => input.id) .handler(async () => {}) ``` @@ -392,7 +368,7 @@ Guard shared middleware yourself with the context flag pattern from [Dedupe Midd ```ts const authMiddleware = os - .$context<{ user?: User, authLoaded?: boolean }>() + .$context<{ user?: User; authLoaded?: boolean }>() .middleware(async ({ context, next }) => { if (context.authLoaded) { return next() @@ -622,8 +598,8 @@ import { RPCSerializer } from '@orpc/client' const serializer = new RPCSerializer({ handlers: { user: { - condition: data => data instanceof User, - serialize: data => data.toJSON(), + condition: (data) => data instanceof User, + serialize: (data) => data.toJSON(), deserialize: (data: any) => new User(data.id, data.name), }, }, @@ -638,9 +614,9 @@ import type { StandardRPCCustomJsonSerializer } from '@orpc/client/standard' const userSerializer: StandardRPCCustomJsonSerializer = { type: 21, // unique number > 20 - condition: data => data instanceof User, - serialize: data => data.toJSON(), - deserialize: data => new User(data.id, data.name), + condition: (data) => data instanceof User, + serialize: (data) => data.toJSON(), + deserialize: (data) => new User(data.id, data.name), } const handler = new RPCHandler(router, { customJsonSerializers: [userSerializer] }) @@ -661,7 +637,7 @@ const REGEXP_PATTERN = /^\/([\s\S]*)\/([a-z]*)$/ const serializer = new RPCSerializer({ handlers: { regexp: { - condition: data => data instanceof RegExp, + condition: (data) => data instanceof RegExp, serialize: (data: RegExp) => data.toString(), deserialize: (data: string) => { const [, pattern, flags] = data.match(REGEXP_PATTERN)! @@ -681,7 +657,8 @@ The flat `eventIterator*` handler options moved under the adapter's response opt ```ts v2 const handler = new RPCHandler(router, { - toFetchResponse: { // fetch adapter; node uses sendStandardResponse + toFetchResponse: { + // fetch adapter; node uses sendStandardResponse eventStream: { keepAlive: { enabled: true, interval: 15000, comment: '' }, }, @@ -740,10 +717,7 @@ Handler plugins were renamed with a `HandlerPlugin` suffix. Deprecated aliases e ```ts v2 -import { - RequestLimitHandlerPlugin, - ResponseCompressionHandlerPlugin, -} from '@orpc/server/plugins' +import { RequestLimitHandlerPlugin, ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { plugins: [ @@ -757,10 +731,7 @@ const handler = new RPCHandler(router, { import { BodyLimitPlugin, CompressionPlugin } from '@orpc/server/fetch' const handler = new RPCHandler(router, { - plugins: [ - new BodyLimitPlugin({ maxBodySize: 1024 * 1024 }), - new CompressionPlugin(), - ], + plugins: [new BodyLimitPlugin({ maxBodySize: 1024 * 1024 }), new CompressionPlugin()], }) ``` @@ -835,20 +806,22 @@ The first argument of a custom `fetch` is now the URL string instead of a `Reque ```ts v2 const link = new RPCLink({ url: '/rpc', - fetch: (url, init, { context }) => globalThis.fetch(url, { - ...init, - credentials: 'include', - }), + fetch: (url, init, { context }) => + globalThis.fetch(url, { + ...init, + credentials: 'include', + }), }) ``` ```ts v1 const link = new RPCLink({ url: 'http://localhost:3000/rpc', - fetch: (request, init, { context }) => globalThis.fetch(request, { - ...init, - credentials: 'include', - }), + fetch: (request, init, { context }) => + globalThis.fetch(request, { + ...init, + credentials: 'include', + }), }) ``` @@ -1092,9 +1065,10 @@ const handler = new OpenAPIHandler(router, { plugins: [ new OpenAPIReferenceHandlerPlugin({ provider: 'scalar', - spec: () => generator.generate(router, { - base: { info: { title: 'My App', version: '0.0.0' } }, - }), + spec: () => + generator.generate(router, { + base: { info: { title: 'My App', version: '0.0.0' } }, + }), }), ], }) @@ -1287,10 +1261,12 @@ import { MemoryRateLimiter } from '@orpc/ratelimit/memory' const limiter = new MemoryRateLimiter({ maxRequests: 10, window: 60_000 }) const example = os - .use(ratelimit({ - limiter: () => limiter, - key: ({ context }) => `user:${context.user.id}`, - })) + .use( + ratelimit({ + limiter: () => limiter, + key: ({ context }) => `user:${context.user.id}`, + }), + ) .handler(async () => {}) ``` @@ -1301,10 +1277,12 @@ import { MemoryRatelimiter } from '@orpc/experimental-ratelimit/memory' const limiter = new MemoryRatelimiter({ maxRequests: 10, window: 60_000 }) const example = os - .use(createRatelimitMiddleware({ - limiter: () => limiter, - key: ({ context }) => `user:${context.user.id}`, - })) + .use( + createRatelimitMiddleware({ + limiter: () => limiter, + key: ({ context }) => `user:${context.user.id}`, + }), + ) .handler(async () => {}) ``` @@ -1332,12 +1310,15 @@ import { UpstashRatelimiter } from '@orpc/experimental-ratelimit/upstash-ratelim import { Ratelimit } from '@upstash/ratelimit' import { Redis } from '@upstash/redis' -const limiter = new UpstashRatelimiter(new Ratelimit({ - redis: Redis.fromEnv(), - limiter: Ratelimit.fixedWindow(10, '60 s'), -}), { - blockingUntilReady: { enabled: true, timeout: 5_000 }, -}) +const limiter = new UpstashRatelimiter( + new Ratelimit({ + redis: Redis.fromEnv(), + limiter: Ratelimit.fixedWindow(10, '60 s'), + }), + { + blockingUntilReady: { enabled: true, timeout: 5_000 }, + }, +) ``` diff --git a/apps/content/docs/migrations/index.mdx b/apps/content/docs/migrations/index.mdx index 5733d59314..e4943b25e3 100644 --- a/apps/content/docs/migrations/index.mdx +++ b/apps/content/docs/migrations/index.mdx @@ -1,8 +1,8 @@ --- -title: "Migrations" -description: "Guides for moving to oRPC v2: upgrade an existing oRPC v1 app, or migrate a tRPC app step by step or incrementally alongside your current routers." +title: 'Migrations' +description: 'Guides for moving to oRPC v2: upgrade an existing oRPC v1 app, or migrate a tRPC app step by step or incrementally alongside your current routers.' seo: - title: "Migration Guides: From tRPC or oRPC v1" + title: 'Migration Guides: From tRPC or oRPC v1' sidebar: hidden: true --- diff --git a/apps/content/docs/migrations/meta.ts b/apps/content/docs/migrations/meta.ts index 84814a645e..1536a00972 100644 --- a/apps/content/docs/migrations/meta.ts +++ b/apps/content/docs/migrations/meta.ts @@ -1,8 +1,5 @@ import { defineMeta } from 'blume' export default defineMeta({ - pages: [ - 'from-v1', - 'from-trpc', - ], + pages: ['from-v1', 'from-trpc'], }) diff --git a/apps/content/docs/openapi/adapters.mdx b/apps/content/docs/openapi/adapters.mdx index 64804e64f5..2691e26708 100644 --- a/apps/content/docs/openapi/adapters.mdx +++ b/apps/content/docs/openapi/adapters.mdx @@ -1,6 +1,6 @@ --- -title: "OpenAPI Adapters" -description: "The HTTP adapters that run OpenAPI Handler and OpenAPI Link, and where to import each one." +title: 'OpenAPI Adapters' +description: 'The HTTP adapters that run OpenAPI Handler and OpenAPI Link, and where to import each one.' --- OpenAPI runs over HTTP only. Pick the adapter for your server, then import [OpenAPI Handler](/docs/openapi/handler) from it. For WebSocket or message ports, use [RPC](/docs/rpc/adapters). diff --git a/apps/content/docs/openapi/bracket-notation.mdx b/apps/content/docs/openapi/bracket-notation.mdx index 0d9f94b0dc..4261459e2c 100644 --- a/apps/content/docs/openapi/bracket-notation.mdx +++ b/apps/content/docs/openapi/bracket-notation.mdx @@ -1,6 +1,6 @@ --- -title: "Bracket Notation" -description: "Learn how bracket notation encodes structured data like nested objects and arrays in flat key-value formats such as query strings and form data." +title: 'Bracket Notation' +description: 'Learn how bracket notation encodes structured data like nested objects and arrays 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 bracket notation whenever nested data must be represented outside plain JSON. @@ -34,7 +34,7 @@ description: "Learn how bracket notation encodes structured data like nested obj const serializer = new OpenAPISerializer({ bracketNotation: { maxDeserializingEmptySlots: 10_000, - } + }, }) ``` diff --git a/apps/content/docs/openapi/expanding-type-support-for-link.mdx b/apps/content/docs/openapi/expanding-type-support-for-link.mdx index 594a8d5ed6..4c6772e6e3 100644 --- a/apps/content/docs/openapi/expanding-type-support-for-link.mdx +++ b/apps/content/docs/openapi/expanding-type-support-for-link.mdx @@ -1,6 +1,6 @@ --- -title: "Expanding Type Support for OpenAPI Link" -description: "Restore native types like Date and bigint on OpenAPI Link clients using the Response Validation or Smart Coercion plugins." +title: 'Expanding Type Support for OpenAPI Link' +description: 'Restore native types like Date and bigint on OpenAPI Link clients using the Response Validation or Smart Coercion plugins.' --- 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. @@ -21,10 +21,12 @@ Nested `Blob` and `File` values are still limited by [Bracket Notation](/docs/op 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 contract = oc.output( + z.object({ + date: z.coerce.date(), + bigint: z.coerce.bigint(), + }), +) const procedure = implement(contract).handler(() => ({ date: new Date(), diff --git a/apps/content/docs/openapi/handler.mdx b/apps/content/docs/openapi/handler.mdx index 8b520409ad..91b69635f5 100644 --- a/apps/content/docs/openapi/handler.mdx +++ b/apps/content/docs/openapi/handler.mdx @@ -1,6 +1,6 @@ --- -title: "OpenAPI Handler" -description: "Use OpenAPIHandler to expose oRPC procedures as HTTP endpoints for OpenAPI Link and other OpenAPI-compliant clients." +title: 'OpenAPI Handler' +description: 'Use OpenAPIHandler to expose oRPC procedures as HTTP endpoints for OpenAPI Link and other OpenAPI-compliant clients.' --- ## Overview @@ -13,19 +13,15 @@ const handler = new OpenAPIHandler(router, { try { return await next() - } - catch (err) { + } catch (err) { console.error(`${path.join('.')}:`, err) throw err - } - finally { + } finally { console.timeEnd(path.join('.')) } - } - ], - plugins: [ - new CORSHandlerPlugin() + }, ], + plugins: [new CORSHandlerPlugin()], }) ``` @@ -36,7 +32,7 @@ The actual usage of `OpenAPIHandler` depends on the [adapter](/docs/openapi/adap export async function fetch(request: Request) { const { matched, response } = await handler.handle(request, { prefix: '/api', - context: {} // <- provide initial context if needed + context: {}, // <- provide initial context if needed }) if (matched) { @@ -111,8 +107,7 @@ const handler = new OpenAPIHandler(router, { try { const response = await next() return response - } - catch (err) { + } catch (err) { if (err instanceof CustomError) { throw new ORPCError('CUSTOM_ERROR', { message: err.message, cause: err }) } @@ -126,12 +121,10 @@ const handler = new OpenAPIHandler(router, { try { const response = await next() return response - } - catch (err) { + } catch (err) { console.error(`${path.join('.')}:`, err) throw err - } - finally { + } finally { console.timeEnd(path.join('.')) } }, @@ -160,9 +153,7 @@ Plugins package reusable interceptors. For example, [CORS Plugin](/docs/plugins/ ```ts const handler = new OpenAPIHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], }) ``` diff --git a/apps/content/docs/openapi/index.mdx b/apps/content/docs/openapi/index.mdx index a0ad77fefd..370f9d3a19 100644 --- a/apps/content/docs/openapi/index.mdx +++ b/apps/content/docs/openapi/index.mdx @@ -1,8 +1,8 @@ --- -title: "OpenAPI" -description: "Serve oRPC procedures as a REST API, generate an OpenAPI 3.x document from the same code, call it with a typed client, and render interactive API docs." +title: 'OpenAPI' +description: 'Serve oRPC procedures as a REST API, generate an OpenAPI 3.x document from the same code, call it with a typed client, and render interactive API docs.' seo: - title: "OpenAPI: REST APIs and Generated Specs" + title: 'OpenAPI: REST APIs and Generated Specs' sidebar: hidden: true --- diff --git a/apps/content/docs/openapi/input-and-output-mapping.mdx b/apps/content/docs/openapi/input-and-output-mapping.mdx index 19268185ee..ff9253af23 100644 --- a/apps/content/docs/openapi/input-and-output-mapping.mdx +++ b/apps/content/docs/openapi/input-and-output-mapping.mdx @@ -1,8 +1,8 @@ --- -title: "OpenAPI Input and Output Mapping" -description: "Learn how oRPC maps OpenAPI requests and responses to procedure inputs and outputs with compact and detailed structures, parameter styles, and body hints." +title: 'OpenAPI Input and Output Mapping' +description: 'Learn how oRPC maps OpenAPI requests and responses to procedure inputs and outputs with compact and detailed structures, parameter styles, and body hints.' sidebar: - label: "Input and Output Mapping" + label: 'Input and Output Mapping' --- ## Input Mapping @@ -12,10 +12,12 @@ By default, oRPC uses `compact` mode where path parameters are merged with eithe ```ts const searchPlanets = os .meta(openapi({ method: 'GET', path: '/planets/{id}' })) - .input(z.object({ - id: z.string(), - q: z.string().optional(), - })) + .input( + z.object({ + id: z.string(), + q: z.string().optional(), + }), + ) .handler(async ({ input }) => { return { id: input.id, q: input.q } }) @@ -40,17 +42,21 @@ In `detailed` mode, the input is an object with separate `params`, `query`, `hea ```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() }), - })) + .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 }) @@ -77,21 +83,25 @@ By default, path parameters are decoded as plain strings. Use `paramsStyles` to ```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(), + .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 () => []) ``` @@ -113,16 +123,18 @@ By default, query parameters are decoded with [bracket notation](/docs/openapi/b ```ts const searchPlanets = os - .meta(openapi({ - method: 'GET', - path: '/planets', - queryStyles: { - keyword: 'primitive', - tags: 'comma-delimited-array', - filters: 'comma-delimited-object', - meta: 'json', - }, - })) + .meta( + openapi({ + method: 'GET', + path: '/planets', + queryStyles: { + keyword: 'primitive', + tags: 'comma-delimited-array', + filters: 'comma-delimited-object', + meta: 'json', + }, + }), + ) .handler(async () => []) ``` @@ -167,23 +179,27 @@ In `detailed` mode, return an object with the following fields: ```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() }), + .meta( + openapi({ + method: 'PUT', + path: '/planets/{id}', + outputStructure: 'detailed', + successStatus: 200, }), - z.object({ - status: z.literal(200).meta({ description: 'Updated' }), - body: z.object({ id: z.string(), name: z.string() }), - }), - ])) + ) + .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 { @@ -205,15 +221,19 @@ Combine `successStatus` with the [detailed output structure](#detailed-output-st ```ts const redirect = os - .meta(openapi({ - method: 'GET', - path: '/redirect', - successStatus: 307, - outputStructure: 'detailed', - })) - .output(z.object({ - headers: z.object({ location: z.string() }), - })) + .meta( + openapi({ + method: 'GET', + path: '/redirect', + successStatus: 307, + outputStructure: 'detailed', + }), + ) + .output( + z.object({ + headers: z.object({ location: z.string() }), + }), + ) .handler(async () => { return { headers: { location: 'https://orpc.dev' }, @@ -231,10 +251,12 @@ The body parser normally uses `Content-Type`, `Content-Length`, `Content-Disposi ```ts const uploadLargeFile = os - .meta(openapi({ - requestBodyHint: 'octet-stream', - responseBodyHint: 'json', - })) + .meta( + openapi({ + requestBodyHint: 'octet-stream', + responseBodyHint: 'json', + }), + ) .input(z.instanceof(ReadableStream)) .handler(async ({ input }) => { for await (const chunk of input) { @@ -266,16 +288,14 @@ Learn more about body hints in the [Standard Server documentation](https://githu When `openapi` is applied multiple times, `paramsStyles` and `queryStyles` are merged per parameter, and the most recent style defined for a parameter wins. `inputStructure`, `outputStructure`, `responseBodyHint`, and `requestBodyHint` are overridden by the most recent call. For the full merge behavior of every field, see [Metadata Merging](/docs/openapi/specification#metadata-merging). ```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 () => ([])), - }) +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: @@ -283,17 +303,19 @@ These are equivalent to: ```ts const router = { get: os - .meta(openapi({ - method: 'GET', - path: '/planets', - inputStructure: 'compact', - queryStyles: { - tags: 'comma-delimited-array', - q: 'primitive', - }, - })) + .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 () => ([])), + .handler(async () => []), } ``` diff --git a/apps/content/docs/openapi/link-without-runtime-imports.mdx b/apps/content/docs/openapi/link-without-runtime-imports.mdx index 922f6ba08a..e031585ad8 100644 --- a/apps/content/docs/openapi/link-without-runtime-imports.mdx +++ b/apps/content/docs/openapi/link-without-runtime-imports.mdx @@ -1,6 +1,6 @@ --- -title: "OpenAPI Link Without Runtime Imports" -description: "Use build-time macros to embed a minified contract in your client bundle, so OpenAPI Link works without a separate contract or shipping server code." +title: 'OpenAPI Link Without Runtime Imports' +description: 'Use build-time macros to embed a minified contract in your client bundle, so OpenAPI Link works without a separate contract or shipping server code.' --- [OpenAPI Link](/docs/openapi/link) needs a contract at runtime to know each procedure's method and path. With [RPC Link](/docs/rpc/link) a type import is enough, but with OpenAPI Link you must either maintain a [contract](/docs/contract/router) or import your [router](/docs/router), which pulls server code into the client bundle. [Safely Importing Router on the Client](/docs/contract/router#safely-importing-router-on-the-client) avoids that by exporting a minified contract to a JSON file, but you must regenerate the file every time the router changes. @@ -62,7 +62,9 @@ The `router` import is type-only, so it is erased at compile time and never reac ```js const link = new OpenAPILink({ planet: { - find: { '~orpc': { errorMap: {}, meta: { '~openapi': { method: 'GET', path: '/planets/{id}' } } } }, + find: { + '~orpc': { errorMap: {}, meta: { '~openapi': { method: 'GET', path: '/planets/{id}' } } }, + }, }, // ... }) diff --git a/apps/content/docs/openapi/link.mdx b/apps/content/docs/openapi/link.mdx index 951dcbf0ae..9b4ac5aaba 100644 --- a/apps/content/docs/openapi/link.mdx +++ b/apps/content/docs/openapi/link.mdx @@ -1,6 +1,6 @@ --- -title: "OpenAPI Link" -description: "Use OpenAPILink to call HTTP endpoints served by OpenAPI Handler or any OpenAPI-compliant server through a typesafe oRPC client." +title: 'OpenAPI Link' +description: 'Use OpenAPILink to call HTTP endpoints served by OpenAPI Handler or any OpenAPI-compliant server through a typesafe oRPC client.' --- ## Overview @@ -18,16 +18,14 @@ const link = new OpenAPILink(contract, { try { return await next() - } - finally { + } finally { console.timeEnd(path.join('.')) } }, ], - plugins: [ - new RetryAfterLinkPlugin(), - ], - fetch: (url, init) => { // <- only available in fetch adapter + plugins: [new RetryAfterLinkPlugin()], + fetch: (url, init) => { + // <- only available in fetch adapter return globalThis.fetch(url, { ...init, credentials: 'include', // Include cookies on cross-origin requests @@ -59,7 +57,8 @@ import { JsonifiedClient } from '@orpc/openapi' import { RouterClient } from '@orpc/server' // if you are following contract-first approach -const contractClient: JsonifiedClient> = createORPCClient(link) +const contractClient: JsonifiedClient> = + createORPCClient(link) // if you are following normal approach const routerClient: JsonifiedClient> = createORPCClient(link) @@ -159,12 +158,10 @@ const link = new OpenAPILink(contract, { try { const output = await next() return output - } - catch (err) { + } catch (err) { console.error(`${path.join('.')}:`, err) throw err - } - finally { + } finally { console.timeEnd(path.join('.')) } }, @@ -203,9 +200,7 @@ Plugins package reusable interceptors. For example, [Retry After Plugin](/docs/p ```ts const link = new OpenAPILink(contract, { - plugins: [ - new RetryAfterLinkPlugin(), - ], + plugins: [new RetryAfterLinkPlugin()], }) ``` @@ -262,7 +257,11 @@ const link = new OpenAPILink(contract, { interceptors: [ onError((error) => { if (error instanceof ORPCError && error.cause instanceof ResponseError) { - console.error('Request failed:', error.cause.response.status, error.cause.response.headers['x-request-id']) + console.error( + 'Request failed:', + error.cause.response.status, + error.cause.response.headers['x-request-id'], + ) } }), ], diff --git a/apps/content/docs/openapi/routing.mdx b/apps/content/docs/openapi/routing.mdx index 1b59c28d9a..05ddbf38bf 100644 --- a/apps/content/docs/openapi/routing.mdx +++ b/apps/content/docs/openapi/routing.mdx @@ -1,6 +1,6 @@ --- -title: "OpenAPI Routing" -description: "Use openapi metadata to control how procedures are exposed over HTTP, including methods, paths, path parameters, and prefixes." +title: 'OpenAPI Routing' +description: 'Use openapi metadata to control how procedures are exposed over HTTP, including methods, paths, path parameters, and prefixes.' --- ## Basic Routing @@ -17,9 +17,8 @@ const router = { list: os .meta(openapi({ method: 'GET', path: '/planets' })) .handler(async () => [{ id: 'earth', name: 'Earth' }]), - create: os - .handler(async () => ({})), - } + create: os.handler(async () => ({})), + }, } ``` @@ -62,8 +61,7 @@ const listPlanets = planetBuilder .meta(openapi({ method: 'GET', path: '/' })) .handler(async () => [{ id: 'earth', name: 'Earth' }]) -const createPlanet = planetBuilder - .handler(async () => ({})) +const createPlanet = planetBuilder.handler(async () => ({})) const router = os.meta(openapi({ prefix: '/api/v2' })).router({ planet: { @@ -103,9 +101,7 @@ When using a [lazy router](/docs/router#lazy-router), define a `prefix` so lazy ```ts const router = { - project: os - .meta(openapi({ prefix: '/projects' })) - .lazy(() => import('./project')), + project: os.meta(openapi({ prefix: '/projects' })).lazy(() => import('./project')), } ``` @@ -123,19 +119,18 @@ const router = base.router({ ping: base.handler(() => 'pong'), // prefix: '/api/api' }) ``` + ::: ```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 () => ({})), - }) +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: @@ -143,11 +138,13 @@ These calls are equivalent to: ```ts const router = { get: os - .meta(openapi({ - prefix: '/api/v2/planets', - method: 'GET', - path: '/{id}', - })) + .meta( + openapi({ + prefix: '/api/v2/planets', + method: 'GET', + path: '/{id}', + }), + ) .handler(async () => ({})), } ``` @@ -156,9 +153,7 @@ const router = { 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 })) +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`. @@ -188,7 +183,7 @@ const ping = base method: 'GET', path: '/ping', }) - .input(z.object({ name: z.string(), })) + .input(z.object({ name: z.string() })) .handler(async ({ input }) => { return `Hello ${input.name}!` }) diff --git a/apps/content/docs/openapi/scalar.mdx b/apps/content/docs/openapi/scalar.mdx index 84b6d8dcc0..ab37c8a008 100644 --- a/apps/content/docs/openapi/scalar.mdx +++ b/apps/content/docs/openapi/scalar.mdx @@ -1,6 +1,6 @@ --- -title: "Scalar (Swagger)" -description: "Use Scalar to serve an interactive API reference UI for your oRPC API generated from its OpenAPI specification." +title: 'Scalar (Swagger)' +description: 'Use Scalar to serve an interactive API reference UI for your oRPC API generated from its OpenAPI specification.' --- :::info @@ -19,15 +19,11 @@ import { CORSHandlerPlugin } from '@orpc/server/plugins' import { ZodToJsonSchemaConverter } from '@orpc/zod' const openAPIHandler = new OpenAPIHandler(router, { - plugins: [ - new CORSHandlerPlugin(), - ], + plugins: [new CORSHandlerPlugin()], }) const openAPIGenerator = new OpenAPIGenerator({ - converters: [ - new ZodToJsonSchemaConverter(), - ], + converters: [new ZodToJsonSchemaConverter()], }) const server = createServer(async (req, res) => { @@ -46,9 +42,7 @@ const server = createServer(async (req, res) => { title: 'My Playground', version: '1.0.0', }, - servers: [ - { url: '/api' }, /** Use an absolute URL in production. */ - ], + servers: [{ url: '/api' } /** Use an absolute URL in production. */], security: [{ bearerAuth: [] }], components: { securitySchemes: { diff --git a/apps/content/docs/openapi/serializer.mdx b/apps/content/docs/openapi/serializer.mdx index c41b735bdf..989a0a8fb2 100644 --- a/apps/content/docs/openapi/serializer.mdx +++ b/apps/content/docs/openapi/serializer.mdx @@ -1,6 +1,6 @@ --- -title: "OpenAPI Serializer" -description: "Learn how OpenAPI Serializer performs one-way serialization of complex types like Date, BigInt, and files into JSON-friendly formats, and how to customize it." +title: 'OpenAPI Serializer' +description: 'Learn how OpenAPI Serializer performs one-way serialization of complex types like Date, BigInt, and files into JSON-friendly formats, and how to customize it.' --- ## Supported Data Types @@ -66,12 +66,14 @@ import { OpenAPISerializer } from '@orpc/openapi' const serializer = new OpenAPISerializer({ handlers: { - person: { // <- add support for Person - condition: v => v instanceof Person, + 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, + date: { + // <- replace the default Date handler + condition: (v) => v instanceof Date, serialize: (v: Date) => v.getTime(), }, }, diff --git a/apps/content/docs/openapi/specification.mdx b/apps/content/docs/openapi/specification.mdx index 61db206646..22bdb45511 100644 --- a/apps/content/docs/openapi/specification.mdx +++ b/apps/content/docs/openapi/specification.mdx @@ -1,6 +1,6 @@ --- -title: "OpenAPI Specification" -description: "Learn how to configure openapi metadata and generate OpenAPI 3.2, 3.1, or 3.0 documents from your oRPC contracts and routers with OpenAPIGenerator." +title: 'OpenAPI Specification' +description: 'Learn how to configure openapi metadata and generate OpenAPI 3.2, 3.1, or 3.0 documents from your oRPC contracts and routers with OpenAPIGenerator.' --- ## Metadata @@ -13,23 +13,29 @@ 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(), - })) + .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 @@ -42,14 +48,16 @@ Use `spec` to customize the generated operation object. If `spec` is an object, ```ts const getPlanet = oc - .meta(openapi({ - method: 'GET', - path: '/planets/{id}', - spec: current => ({ - ...current, - security: [{ bearerAuth: [] }], + .meta( + openapi({ + method: 'GET', + path: '/planets/{id}', + spec: (current) => ({ + ...current, + security: [{ bearerAuth: [] }], + }), }), - })) + ) .input(z.object({ id: z.string() })) ``` @@ -73,33 +81,38 @@ const router = base.router({ list: base.handler(() => []), // prefix: '/api/api' }) ``` + ::: ```ts const router = os - .meta(openapi({ - tags: ['planets'], - spec: current => ({ - ...current, - security: [{ bearerAuth: [] }], + .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', + .meta( + openapi({ + spec: { + operationId: 'getPlanet', + summary: 'List planets', + responses: { + 200: { + description: 'List of planets', + }, }, - } - } - })) + }, + }), + ) .input(z.object({ q: z.string().optional() })) - .handler(async () => ([])), + .handler(async () => []), }) ``` @@ -108,23 +121,25 @@ These are equivalent to: ```ts const router = { list: os - .meta(openapi({ - method: 'GET', - tags: ['planets', 'list'], - summary: 'List planets', - spec: { - operationId: 'getPlanet', + .meta( + openapi({ + method: 'GET', + tags: ['planets', 'list'], summary: 'List planets', - responses: { - 200: { - description: 'List of planets', + spec: { + operationId: 'getPlanet', + summary: 'List planets', + responses: { + 200: { + description: 'List of planets', + }, }, + security: [{ bearerAuth: [] }], }, - security: [{ bearerAuth: [] }], - }, - })) + }), + ) .input(z.object({ q: z.string().optional() })) - .handler(async () => ([])), + .handler(async () => []), } ``` @@ -132,9 +147,7 @@ const router = { 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 })) +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`. @@ -159,9 +172,7 @@ const spec = await generator.generate(router, { title: 'Planet API', version: '1.0.0', }, - servers: [ - { url: 'https://example.com/api' }, - ], + servers: [{ url: 'https://example.com/api' }], }, }) ``` diff --git a/apps/content/docs/playgrounds.mdx b/apps/content/docs/playgrounds.mdx index 219057292c..150704992b 100644 --- a/apps/content/docs/playgrounds.mdx +++ b/apps/content/docs/playgrounds.mdx @@ -1,6 +1,6 @@ --- -title: "Playgrounds" -description: "Try oRPC instantly with pre-configured playground examples for Bun, Cloudflare, NestJS, Next.js, and Expo on StackBlitz or locally." +title: 'Playgrounds' +description: 'Try oRPC instantly with pre-configured playground examples for Bun, Cloudflare, NestJS, Next.js, and Expo on StackBlitz or locally.' sidebar: icon: joystick --- diff --git a/apps/content/docs/plugins/batch-response-compression.mdx b/apps/content/docs/plugins/batch-response-compression.mdx index 27f8110587..0f4cf77a2f 100644 --- a/apps/content/docs/plugins/batch-response-compression.mdx +++ b/apps/content/docs/plugins/batch-response-compression.mdx @@ -1,8 +1,8 @@ --- -title: "Batch Response Compression Plugin" -description: "Compress batch responses when you know their contents compress, flushing each one as it is ready so a streaming batch stays streaming." +title: 'Batch Response Compression Plugin' +description: 'Compress batch responses when you know their contents compress, flushing each one as it is ready so a streaming batch stays streaming.' sidebar: - label: "Batch Response Compression" + label: 'Batch Response Compression' --- ## Installation diff --git a/apps/content/docs/plugins/batch.mdx b/apps/content/docs/plugins/batch.mdx index 224fa9b768..105c64d69f 100644 --- a/apps/content/docs/plugins/batch.mdx +++ b/apps/content/docs/plugins/batch.mdx @@ -1,8 +1,8 @@ --- -title: "Batch Plugin" -description: "Combine multiple requests into a single batch and receive their responses together, reducing the overhead of separate requests." +title: 'Batch Plugin' +description: 'Combine multiple requests into a single batch and receive their responses together, reducing the overhead of separate requests.' sidebar: - label: "Batch" + label: 'Batch' --- :::warning @@ -19,9 +19,7 @@ Set up batching on both the server and the client. The server plugin handles inc import { BatchHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { - plugins: [ - new BatchHandlerPlugin(), - ], + plugins: [new BatchHandlerPlugin()], }) ``` @@ -146,10 +144,7 @@ A batch response might be left uncompressed by the [Response Compression Plugin] import { BatchResponseCompressionHandlerPlugin } from '@orpc/node' const handler = new RPCHandler(router, { - plugins: [ - new BatchHandlerPlugin(), - new BatchResponseCompressionHandlerPlugin(), - ], + plugins: [new BatchHandlerPlugin(), new BatchResponseCompressionHandlerPlugin()], }) ``` @@ -177,21 +172,24 @@ const link = new RPCLink({ groups: [ { condition: ({ context }) => context?.cache === 'force-cache', - context: { // used for the rest of the request lifecycle + context: { + // used for the rest of the request lifecycle cache: 'force-cache', }, }, - { // Fallback for all other requests. Keep this last. + { + // Fallback for all other requests. Keep this last. condition: () => true, context: {}, }, ], }), ], - fetch: (url, init, { context }) => globalThis.fetch(url, { - ...init, - cache: context?.cache, - }), + fetch: (url, init, { context }) => + globalThis.fetch(url, { + ...init, + cache: context?.cache, + }), }) ``` diff --git a/apps/content/docs/plugins/cookie-jar.mdx b/apps/content/docs/plugins/cookie-jar.mdx index 42bc70aa45..21e0d95e31 100644 --- a/apps/content/docs/plugins/cookie-jar.mdx +++ b/apps/content/docs/plugins/cookie-jar.mdx @@ -1,8 +1,8 @@ --- -title: "Cookie Jar Plugin" -description: "Store the cookies servers set and send them back on later requests, like a browser, for server-side clients." +title: 'Cookie Jar Plugin' +description: 'Store the cookies servers set and send them back on later requests, like a browser, for server-side clients.' sidebar: - label: "Cookie Jar" + label: 'Cookie Jar' --- Clients outside a browser, such as during server-side rendering, in scripts, or in tests, do not keep cookies. `CookieJarLinkPlugin` stores the cookies servers set and sends them back on later requests, following the browser rules of [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265). diff --git a/apps/content/docs/plugins/cors.mdx b/apps/content/docs/plugins/cors.mdx index 31a627b50b..e5474277eb 100644 --- a/apps/content/docs/plugins/cors.mdx +++ b/apps/content/docs/plugins/cors.mdx @@ -1,8 +1,8 @@ --- -title: "CORS Handler Plugin" -description: "Configure CORS policy for your oRPC API with CORSHandlerPlugin, including allowed origins, methods, and exposed headers." +title: 'CORS Handler Plugin' +description: 'Configure CORS policy for your oRPC API with CORSHandlerPlugin, including allowed origins, methods, and exposed headers.' sidebar: - label: "CORS" + label: 'CORS' --- ## Basic @@ -40,7 +40,8 @@ The `origin` and `timingOrigin` options also accept a function (optionally async const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin({ - origin: async (origin, { context }) => context.tenant?.allowedOrigins.includes(origin) ? origin : null, + origin: async (origin, { context }) => + context.tenant?.allowedOrigins.includes(origin) ? origin : null, }), ], }) diff --git a/apps/content/docs/plugins/dedupe.mdx b/apps/content/docs/plugins/dedupe.mdx index 55e73e74e4..28ae014a0c 100644 --- a/apps/content/docs/plugins/dedupe.mdx +++ b/apps/content/docs/plugins/dedupe.mdx @@ -1,8 +1,8 @@ --- -title: "Dedupe Plugin" -description: "Prevent redundant requests by deduplicating similar in-flight requests, reducing the number of requests sent to the server." +title: 'Dedupe Plugin' +description: 'Prevent redundant requests by deduplicating similar in-flight requests, reducing the number of requests sent to the server.' sidebar: - label: "Dedupe" + label: 'Dedupe' --- ## Overview @@ -81,21 +81,24 @@ const link = new RPCLink({ groups: [ { condition: ({ context }) => context?.cache === 'force-cache', - context: { // used for the rest of the request lifecycle + context: { + // used for the rest of the request lifecycle cache: 'force-cache', }, }, - { // Fallback for all other requests. Keep this last. + { + // Fallback for all other requests. Keep this last. condition: () => true, context: {}, }, ], }), ], - fetch: (url, init, { context }) => globalThis.fetch(url, { - ...init, - cache: context?.cache, - }), + fetch: (url, init, { context }) => + globalThis.fetch(url, { + ...init, + cache: context?.cache, + }), }) ``` diff --git a/apps/content/docs/plugins/get-method-csrf-protection.mdx b/apps/content/docs/plugins/get-method-csrf-protection.mdx index d7a46b6587..9dc8ff129c 100644 --- a/apps/content/docs/plugins/get-method-csrf-protection.mdx +++ b/apps/content/docs/plugins/get-method-csrf-protection.mdx @@ -1,22 +1,22 @@ --- -title: "GET Method CSRF Protection Plugin" -description: "Use GetMethodCsrfProtectionHandlerPlugin to make GET as secure as POST by rejecting cross-site navigations that may carry SameSite=Lax cookies." +title: 'GET Method CSRF Protection Plugin' +description: 'Use GetMethodCsrfProtectionHandlerPlugin to make GET as secure as POST by rejecting cross-site navigations that may carry SameSite=Lax cookies.' sidebar: - label: "GET Method CSRF Protection" + label: 'GET Method CSRF Protection' --- ## How It Works Cross-site, browsers withhold explicitly marked [`SameSite=Lax`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#samesitesamesite-value) cookies from unsafe methods such as `POST`, but still attach them to [safe methods](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP) like `GET` on top-level navigations, per [RFC 6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/). The plugin closes that gap by rejecting exactly those navigations with a `403`: -| Request from another site | Sends `SameSite=Lax` cookies | | -| --- | --- | --- | -| link click, redirect, `window.open`, GET form | yes | rejected | -| address bar, bookmark, link from an email or native app | yes | rejected | -| `fetch`, `XMLHttpRequest` | no | allowed, CORS governs the response | -| ``, `', + docsHead: + '', providerConfig: { presets: ['SwaggerUIBundle.presets.apis', 'SwaggerUIStandalonePreset'], plugins: ['SwaggerUIBundle.plugins.DownloadUrl'], @@ -110,7 +108,7 @@ const handler = new OpenAPIHandler(router, { spec: () => generator.generate(router), allow: async ({ context }) => context.user !== undefined, }), - ] + ], }) ``` diff --git a/apps/content/docs/plugins/request-compression.mdx b/apps/content/docs/plugins/request-compression.mdx index 26d0d0d36e..b2676e788b 100644 --- a/apps/content/docs/plugins/request-compression.mdx +++ b/apps/content/docs/plugins/request-compression.mdx @@ -1,8 +1,8 @@ --- -title: "Request Compression Plugin" -description: "Compress request bodies on the client and decompress them on the server to reduce bandwidth usage for large payloads." +title: 'Request Compression Plugin' +description: 'Compress request bodies on the client and decompress them on the server to reduce bandwidth usage for large payloads.' sidebar: - label: "Request Compression" + label: 'Request Compression' --- ## Client @@ -56,7 +56,10 @@ const link = new RPCLink({ * @default isCompressibleContentType (covers common text-based formats) */ isCompressibleContentType: (contentType, { path }) => { - return /^application\/x-custom-format(?:[;\s]|$)/i.test(contentType ?? '') && path[0] !== 'uploads' + return ( + /^application\/x-custom-format(?:[;\s]|$)/i.test(contentType ?? '') && + path[0] !== 'uploads' + ) }, }), ], @@ -71,9 +74,7 @@ Use `RequestCompressionHandlerPlugin` to decompress request bodies. The plugin a import { RequestCompressionHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { - plugins: [ - new RequestCompressionHandlerPlugin(), - ], + plugins: [new RequestCompressionHandlerPlugin()], }) ``` diff --git a/apps/content/docs/plugins/request-headers.mdx b/apps/content/docs/plugins/request-headers.mdx index 19ee5f7d2e..c246a7fde5 100644 --- a/apps/content/docs/plugins/request-headers.mdx +++ b/apps/content/docs/plugins/request-headers.mdx @@ -1,8 +1,8 @@ --- -title: "Request Headers Plugin" -description: "Expose incoming request headers as context.reqHeaders with RequestHeadersHandlerPlugin for easy access in procedures." +title: 'Request Headers Plugin' +description: 'Expose incoming request headers as context.reqHeaders with RequestHeadersHandlerPlugin for easy access in procedures.' sidebar: - label: "Request Headers" + label: 'Request Headers' --- ## Context Access @@ -42,9 +42,7 @@ Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie manag import { RequestHeadersHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { - plugins: [ - new RequestHeadersHandlerPlugin(), - ], + plugins: [new RequestHeadersHandlerPlugin()], }) ``` diff --git a/apps/content/docs/plugins/request-limit.mdx b/apps/content/docs/plugins/request-limit.mdx index fe734075f6..f5b47624fc 100644 --- a/apps/content/docs/plugins/request-limit.mdx +++ b/apps/content/docs/plugins/request-limit.mdx @@ -1,8 +1,8 @@ --- -title: "Request Limit Plugin" -description: "Restrict the size of incoming request bodies with RequestLimitHandlerPlugin to protect your server from oversized payloads." +title: 'Request Limit Plugin' +description: 'Restrict the size of incoming request bodies with RequestLimitHandlerPlugin to protect your server from oversized payloads.' sidebar: - label: "Request Limit" + label: 'Request Limit' --- ## Setup @@ -38,7 +38,7 @@ This plugin does not apply to peer-based adapters such as [WebSocket](/docs/adap ```ts const MAX_BYTES_PER_MINUTE = 10 * 1024 * 1024 // 10MB -const usage = new WeakMap() +const usage = new WeakMap() Bun.serve({ websocket: { @@ -46,7 +46,7 @@ Bun.serve({ async message(ws, message) { let entry = usage.get(ws) if (!entry || Date.now() >= entry.resetAt) { - usage.set(ws, entry = { bytes: 0, resetAt: Date.now() + 60_000 }) + usage.set(ws, (entry = { bytes: 0, resetAt: Date.now() + 60_000 })) } entry.bytes += Buffer.byteLength(message) diff --git a/apps/content/docs/plugins/request-validation.mdx b/apps/content/docs/plugins/request-validation.mdx index 6f874e5085..1c448f220e 100644 --- a/apps/content/docs/plugins/request-validation.mdx +++ b/apps/content/docs/plugins/request-validation.mdx @@ -1,8 +1,8 @@ --- -title: "Request Validation Plugin" -description: "Validate requests against your contract on the client before sending them to the server, catching invalid input early." +title: 'Request Validation Plugin' +description: 'Validate requests against your contract on the client before sending them to the server, catching invalid input early.' sidebar: - label: "Request Validation" + label: 'Request Validation' --- ## Setup @@ -11,9 +11,7 @@ sidebar: import { RequestValidationLinkPlugin } from '@orpc/contract/plugins' const link = new RPCLink({ - plugins: [ - new RequestValidationLinkPlugin(contract), - ], + plugins: [new RequestValidationLinkPlugin(contract)], }) ``` @@ -48,27 +46,24 @@ import { ORPCError } from '@orpc/client' import { ValidationError } from '@orpc/contract' const link = new RPCLink({ - plugins: [ - new RequestValidationLinkPlugin(contract), - ], + plugins: [new RequestValidationLinkPlugin(contract)], interceptors: [ async ({ next }) => { try { return await next() - } - catch (error) { + } catch (error) { if ( - error instanceof ORPCError - && error.code === 'BAD_REQUEST' - && error.cause instanceof ValidationError + error instanceof ORPCError && + error.code === 'BAD_REQUEST' && + error.cause instanceof ValidationError ) { throw new CustomInputValidationError(error.cause.issues) } throw error } - } - ] + }, + ], }) ``` @@ -86,8 +81,7 @@ export function ContactForm() { try { const output = await client.someProcedure(parseFormData(form)) console.log(output) - } - catch (error) { + } catch (error) { setError(error) } } diff --git a/apps/content/docs/plugins/response-compression.mdx b/apps/content/docs/plugins/response-compression.mdx index 6e548da96d..90788583b9 100644 --- a/apps/content/docs/plugins/response-compression.mdx +++ b/apps/content/docs/plugins/response-compression.mdx @@ -1,8 +1,8 @@ --- -title: "Response Compression Plugin" -description: "Compress response bodies on the server and decompress them on the client to reduce bandwidth usage and improve performance." +title: 'Response Compression Plugin' +description: 'Compress response bodies on the server and decompress them on the client to reduce bandwidth usage and improve performance.' sidebar: - label: "Response Compression" + label: 'Response Compression' --- ## Server @@ -62,7 +62,10 @@ const handler = new RPCHandler(router, { * @default isCompressibleContentType (covers common text-based formats) */ isCompressibleContentType: (contentType, { request }) => { - return /^application\/x-custom-format(?:[;\s]|$)/i.test(contentType ?? '') && request.headers['x-no-compression'] === undefined + return ( + /^application\/x-custom-format(?:[;\s]|$)/i.test(contentType ?? '') && + request.headers['x-no-compression'] === undefined + ) }, }), ], diff --git a/apps/content/docs/plugins/response-headers.mdx b/apps/content/docs/plugins/response-headers.mdx index 48f711d614..cec3677a04 100644 --- a/apps/content/docs/plugins/response-headers.mdx +++ b/apps/content/docs/plugins/response-headers.mdx @@ -1,8 +1,8 @@ --- -title: "Response Headers Plugin" -description: "Use ResponseHeadersHandlerPlugin to set response headers and cookies via context.resHeaders and merge them into the final response." +title: 'Response Headers Plugin' +description: 'Use ResponseHeadersHandlerPlugin to set response headers and cookies via context.resHeaders and merge them into the final response.' sidebar: - label: "Response Headers" + label: 'Response Headers' --- ## Context Access @@ -25,7 +25,7 @@ const procedure = base .handler(({ context }) => { setCookie(context.resHeaders, 'session_id', 'abc123', { secure: true, - maxAge: 3600 + maxAge: 3600, }) }) ``` @@ -44,9 +44,7 @@ Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie manag import { ResponseHeadersHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { - plugins: [ - new ResponseHeadersHandlerPlugin(), - ], + plugins: [new ResponseHeadersHandlerPlugin()], }) ``` diff --git a/apps/content/docs/plugins/response-validation.mdx b/apps/content/docs/plugins/response-validation.mdx index 8cf8abcb2b..94fc02d6ff 100644 --- a/apps/content/docs/plugins/response-validation.mdx +++ b/apps/content/docs/plugins/response-validation.mdx @@ -1,8 +1,8 @@ --- -title: "Response Validation Plugin" -description: "Validate server responses against your contract on the client, ensuring returned data matches the types your contract defines." +title: 'Response Validation Plugin' +description: 'Validate server responses against your contract on the client, ensuring returned data matches the types your contract defines.' sidebar: - label: "Response Validation" + label: 'Response Validation' --- ## Setup @@ -11,9 +11,7 @@ sidebar: import { ResponseValidationLinkPlugin } from '@orpc/contract/plugins' const link = new RPCLink({ - plugins: [ - new ResponseValidationLinkPlugin(contract), - ], + plugins: [new ResponseValidationLinkPlugin(contract)], }) ``` @@ -32,7 +30,7 @@ Schemas that transform values into a different type are not supported. **Why?** Consider this schema, which accepts a `number` and transforms it into a `string`: ```ts -const unsupported = z.number().transform(value => value.toString()) +const unsupported = z.number().transform((value) => value.toString()) ``` 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. @@ -50,27 +48,24 @@ import { ORPCError } from '@orpc/client' import { ValidationError } from '@orpc/contract' const link = new RPCLink({ - plugins: [ - new ResponseValidationLinkPlugin(contract), - ], + plugins: [new ResponseValidationLinkPlugin(contract)], interceptors: [ async ({ next }) => { try { return await next() - } - catch (error) { + } catch (error) { if ( - error instanceof ORPCError - && error.code === 'INTERNAL_SERVER_ERROR' - && error.cause instanceof ValidationError + error instanceof ORPCError && + error.code === 'INTERNAL_SERVER_ERROR' && + error.cause instanceof ValidationError ) { throw new CustomOutputValidationError(error.cause.issues) } throw error } - } - ] + }, + ], }) ``` diff --git a/apps/content/docs/plugins/rethrow.mdx b/apps/content/docs/plugins/rethrow.mdx index cba2fc947a..4eaf439e29 100644 --- a/apps/content/docs/plugins/rethrow.mdx +++ b/apps/content/docs/plugins/rethrow.mdx @@ -1,8 +1,8 @@ --- -title: "Rethrow Handler Plugin" +title: 'Rethrow Handler Plugin' description: "Bypass oRPC's built-in error handling and rethrow matching errors to your framework, such as NestJS filters or Express error middleware." sidebar: - label: "Rethrow" + label: 'Rethrow' --- ## Usage @@ -23,8 +23,7 @@ const handler = new RPCHandler(router, { try { await handler.handle(request) -} -catch (error) { +} catch (error) { // If the filter returns true, the error can be caught here } ``` diff --git a/apps/content/docs/plugins/retry-after.mdx b/apps/content/docs/plugins/retry-after.mdx index e8378296b4..3a1a5e38e2 100644 --- a/apps/content/docs/plugins/retry-after.mdx +++ b/apps/content/docs/plugins/retry-after.mdx @@ -1,8 +1,8 @@ --- -title: "Retry After Plugin" -description: "Automatically retry requests based on the Retry-After response header, useful for rate limits and temporary server unavailability." +title: 'Retry After Plugin' +description: 'Automatically retry requests based on the Retry-After response header, useful for rate limits and temporary server unavailability.' sidebar: - label: "Retry After" + label: 'Retry After' --- ## Usage @@ -11,9 +11,7 @@ sidebar: import { RetryAfterLinkPlugin } from '@orpc/client/plugins' const link = new RPCLink({ - plugins: [ - new RetryAfterLinkPlugin(), - ], + plugins: [new RetryAfterLinkPlugin()], }) ``` @@ -33,7 +31,7 @@ By default, the plugin retries only requests that receive a `429` (Too Many Requ const link = new RPCLink({ plugins: [ new RetryAfterLinkPlugin({ - condition: response => [429, 503].includes(response.status), + condition: (response) => [429, 503].includes(response.status), timeout: 5 * 60 * 1000, // 5 minutes maxAttempts: 3, }), diff --git a/apps/content/docs/plugins/retry.mdx b/apps/content/docs/plugins/retry.mdx index 29e512af49..32b02ec81f 100644 --- a/apps/content/docs/plugins/retry.mdx +++ b/apps/content/docs/plugins/retry.mdx @@ -1,8 +1,8 @@ --- -title: "Retry Plugin" -description: "Automatically retry failed oRPC requests with customizable strategies controlled through client context options." +title: 'Retry Plugin' +description: 'Automatically retry failed oRPC requests with customizable strategies controlled through client context options.' sidebar: - label: "Retry" + label: 'Retry' --- :::warning @@ -17,9 +17,7 @@ import { RetryLinkPlugin, RetryLinkPluginContext } from '@orpc/client/plugins' interface ClientContext extends RetryLinkPluginContext {} const link = new RPCLink({ - plugins: [ - new RetryLinkPlugin(), - ], + plugins: [new RetryLinkPlugin()], }) ``` @@ -38,20 +36,23 @@ 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 - } +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 @@ -71,9 +72,9 @@ const link = new RPCLink({ new RetryLinkPlugin({ default: { retry: 0, - retryDelay: o => o.lastEventRetry ?? 2000, - shouldRetry: o => isRetryableError(o.error), - } + retryDelay: (o) => o.lastEventRetry ?? 2000, + shouldRetry: (o) => isRetryableError(o.error), + }, }), ], }) @@ -89,12 +90,16 @@ By default, errors from a 4xx response other than `408` and `429` are not retrie import { ORPCError } from '@orpc/client' import { isRetryableError } from '@orpc/client/plugins' -const planets = await client.planet.list({ limit: 10 }, { - context: { - retry: 3, - shouldRetry: ({ error }) => isRetryableError(error) || (error instanceof ORPCError && error.code === 'CONFLICT'), - } -}) +const planets = await client.planet.list( + { limit: 10 }, + { + context: { + retry: 3, + shouldRetry: ({ error }) => + isRetryableError(error) || (error instanceof ORPCError && error.code === 'CONFLICT'), + }, + }, +) ``` ## Streamed Input @@ -114,7 +119,7 @@ const link = new RPCLink({ plugins: [ new RetryLinkPlugin({ default: { - retry: ({ input }) => isStreamedInput(input) ? 0 : 3, + retry: ({ input }) => (isStreamedInput(input) ? 0 : 3), }, }), ], @@ -133,7 +138,7 @@ To replicate the behavior of [EventSource](https://developer.mozilla.org/en-US/d const streaming = await client.streaming('the input', { context: { retry: Number.POSITIVE_INFINITY, - } + }, }) for await (const message of streaming) { diff --git a/apps/content/docs/plugins/signal-sync.mdx b/apps/content/docs/plugins/signal-sync.mdx index 719aa643f1..3147a9a774 100644 --- a/apps/content/docs/plugins/signal-sync.mdx +++ b/apps/content/docs/plugins/signal-sync.mdx @@ -1,8 +1,8 @@ --- -title: "Signal Sync Plugin" -description: "Keep the request signal and streaming responses in sync, aborting the signal when the body is cancelled and ending the body when the signal aborts." +title: 'Signal Sync Plugin' +description: 'Keep the request signal and streaming responses in sync, aborting the signal when the body is cancelled and ending the body when the signal aborts.' sidebar: - label: "Signal Sync" + label: 'Signal Sync' --- When a client stops reading a streaming response, adapters like [Node.js](/docs/adapters/node-http) abort the request signal, but in-process `fetch` calls and some runtimes behind the [Fetch adapter](/docs/adapters/fetch-api) only cancel the response body, so a procedure waiting on something keeps running. In the other direction, an aborted signal only ends the body once the procedure honors it. @@ -18,9 +18,7 @@ Use `SignalSyncHandlerPlugin` to keep the request signal and streaming outputs ( import { SignalSyncHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { - plugins: [ - new SignalSyncHandlerPlugin(), - ], + plugins: [new SignalSyncHandlerPlugin()], }) ``` diff --git a/apps/content/docs/plugins/smart-coercion.mdx b/apps/content/docs/plugins/smart-coercion.mdx index 4ef0beab9d..7f6520daab 100644 --- a/apps/content/docs/plugins/smart-coercion.mdx +++ b/apps/content/docs/plugins/smart-coercion.mdx @@ -1,8 +1,8 @@ --- -title: "Smart Coercion Plugin" -description: "Automatically coerce request and response values to match your schema types without writing manual coercion logic." +title: 'Smart Coercion Plugin' +description: 'Automatically coerce request and response values to match your schema types without writing manual coercion logic.' sidebar: - label: "Smart Coercion" + label: 'Smart Coercion' --- :::warning @@ -29,8 +29,8 @@ const handler = new OpenAPIHandler(router, { new ZodToJsonSchemaConverter(), // Add other schema converters as needed ], - }) - ] + }), + ], }) ``` @@ -47,7 +47,7 @@ const link = new OpenAPILink(contract, { // Add other schema converters as needed ], }), - ] + ], }) ``` diff --git a/apps/content/docs/plugins/static-file.mdx b/apps/content/docs/plugins/static-file.mdx index f0ec9fec24..c32cab01a3 100644 --- a/apps/content/docs/plugins/static-file.mdx +++ b/apps/content/docs/plugins/static-file.mdx @@ -1,8 +1,8 @@ --- -title: "Static File Plugin" -description: "Serve static files alongside your procedures, with ETag caching, range requests, single page application fallback, and directory traversal protection." +title: 'Static File Plugin' +description: 'Serve static files alongside your procedures, with ETag caching, range requests, single page application fallback, and directory traversal protection.' sidebar: - label: "Static File" + label: 'Static File' --- ## Installation diff --git a/apps/content/docs/plugins/timeout.mdx b/apps/content/docs/plugins/timeout.mdx index 1457346cca..e5334df341 100644 --- a/apps/content/docs/plugins/timeout.mdx +++ b/apps/content/docs/plugins/timeout.mdx @@ -1,8 +1,8 @@ --- -title: "Timeout Plugin" -description: "Abort requests that exceed a timeout on the client or the server, using a static value or a per-request dynamic timeout." +title: 'Timeout Plugin' +description: 'Abort requests that exceed a timeout on the client or the server, using a static value or a per-request dynamic timeout.' sidebar: - label: "Timeout" + label: 'Timeout' --- ## Client @@ -88,7 +88,7 @@ const link = new RPCLink({ const handler = new RPCHandler(router, { plugins: [ new TimeoutHandlerPlugin({ - timeout: ({ path }) => path[0] === 'reports' ? 60_000 : 10_000, + timeout: ({ path }) => (path[0] === 'reports' ? 60_000 : 10_000), }), ], }) diff --git a/apps/content/docs/plugins/tmp-file-upload.mdx b/apps/content/docs/plugins/tmp-file-upload.mdx index 049e93bba4..ab6be7bf79 100644 --- a/apps/content/docs/plugins/tmp-file-upload.mdx +++ b/apps/content/docs/plugins/tmp-file-upload.mdx @@ -1,8 +1,8 @@ --- -title: "Tmp File Upload Plugin" -description: "Stream large file uploads into temporary files instead of memory, so requests far larger than available memory are parsed safely." +title: 'Tmp File Upload Plugin' +description: 'Stream large file uploads into temporary files instead of memory, so requests far larger than available memory are parsed safely.' sidebar: - label: "Tmp File Upload" + label: 'Tmp File Upload' --- ## Installation @@ -47,18 +47,15 @@ Procedures receive ordinary `File` instances. An upload spooled to disk is a `Tm import { TmpFile } from '@orpc/node' import { rename, writeFile } from 'node:fs/promises' -const uploadVideo = os - .input(z.object({ video: z.file() })) - .handler(async ({ input }) => { - const destination = `./videos/${crypto.randomUUID()}` - - if (input.video instanceof TmpFile) { - await rename(input.video.path, destination) - } - else { - await writeFile(destination, input.video.stream()) - } - }) +const uploadVideo = os.input(z.object({ video: z.file() })).handler(async ({ input }) => { + const destination = `./videos/${crypto.randomUUID()}` + + if (input.video instanceof TmpFile) { + await rename(input.video.path, destination) + } else { + await writeFile(destination, input.video.stream()) + } +}) ``` :::warning @@ -141,9 +138,10 @@ const handler = new RPCHandler(router, { new TmpFileUploadHandlerPlugin({ maxBodySize: async ({ context }) => ({ memory: 1024 * 1024, // 1MB - file: context.user === undefined - ? 5 * 1024 * 1024 // 5MB for guests - : 500 * 1024 * 1024, // 500MB for authenticated users + file: + context.user === undefined + ? 5 * 1024 * 1024 // 5MB for guests + : 500 * 1024 * 1024, // 500MB for authenticated users stream: 500 * 1024 * 1024, // 500MB }), }), diff --git a/apps/content/docs/procedure.mdx b/apps/content/docs/procedure.mdx index 2bb6e70ea7..776902f9a1 100644 --- a/apps/content/docs/procedure.mdx +++ b/apps/content/docs/procedure.mdx @@ -1,6 +1,6 @@ --- -title: "Procedure" -description: "Learn how oRPC procedures combine input and output validation, middleware, metadata, and typesafe errors through a composable builder." +title: 'Procedure' +description: 'Learn how oRPC procedures combine input and output validation, middleware, metadata, and typesafe errors through a composable builder.' --- ## Overview @@ -11,20 +11,17 @@ import type { AnyMetaPlugin } from '@orpc/server' declare const someMeta: AnyMetaPlugin -const requireAuth = os - .middleware(({ context, next }) => { - return next({ - context: { - user: { id: 1 } - } - }) +const requireAuth = os.middleware(({ context, next }) => { + return next({ + context: { + user: { id: 1 }, + }, }) +}) -const canEdit = os - .$context<{ user: { id: number } }>() - .middleware(async ({ next }, id: number) => { - return next() - }) +const canEdit = os.$context<{ user: { id: number } }>().middleware(async ({ next }, id: number) => { + return next() +}) // ---cut--- import { os } from '@orpc/server' @@ -34,9 +31,10 @@ const example = os .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 + .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 + .handler(async ({ input, context, errors }) => { + // <- handler logic return { id: 1, name: 'example' } }) ``` @@ -73,8 +71,7 @@ By specifying `.output` or the handler's return type, TypeScript can infer the o Each `.input` object schema validates the original input with the earlier results applied, then the results are merged. This lets you extend a base procedure without repeating its fields. Fields no schema defines are dropped before the handler runs. [Middleware](/docs/middleware) between schemas receives the fields validated so far plus the raw rest of the input, so whole-input middleware like caching works anywhere in the chain. ```ts -const base = os - .input(z.object({ params: z.object({ id: z.string() }) })) +const base = os.input(z.object({ params: z.object({ id: z.string() }) })) const example = base .input(z.object({ params: z.object({ slug: z.string() }) })) @@ -131,7 +128,9 @@ const aMiddleware = os.middleware(async ({ context, next }) => next()) const example = os .use(aMiddleware) // Apply middleware .use(async ({ context, next }) => next()) // Inline middleware - .handler(async ({ context }) => { /* logic */ }) + .handler(async ({ context }) => { + /* logic */ + }) ``` :::warning @@ -152,8 +151,10 @@ const canEdit = os.middleware(async ({ next }, id: string) => { 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 */ }) + .use(canEdit.adaptInput((input) => input.id)) // Adapt input to match middleware's expected shape + .handler(async ({ context }) => { + /* logic */ + }) ``` ::: @@ -166,11 +167,13 @@ Each modification to a builder creates a completely new instance, avoiding refer const pub = os.use(logMiddleware) // Base setup for procedures that publish 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 = authed - .handler(async ({ context }) => { /* logic */ }) +const authedExample = authed.handler(async ({ context }) => { + /* logic */ +}) ``` This pattern helps prevent duplication while maintaining flexibility. diff --git a/apps/content/docs/recipes/dedupe-middleware.mdx b/apps/content/docs/recipes/dedupe-middleware.mdx index 956716d0ce..ebadef2cd5 100644 --- a/apps/content/docs/recipes/dedupe-middleware.mdx +++ b/apps/content/docs/recipes/dedupe-middleware.mdx @@ -1,6 +1,6 @@ --- -title: "Dedupe Middleware" -description: "Learn how to use context to prevent the same middleware from repeating expensive work when it runs multiple times in a single call." +title: 'Dedupe Middleware' +description: 'Learn how to use context to prevent the same middleware from repeating expensive work when it runs multiple times in a single call.' --- ## Problem @@ -26,12 +26,14 @@ import { os } from '@orpc/server' declare function loadAuth(headers: Headers): Promise<{ id: string } | undefined> // ---cut--- const authProvider = os - .$context<{ headers: Headers, auth?: { id: string } | undefined, authLoaded?: boolean | undefined }>() + .$context<{ + headers: Headers + auth?: { id: string } | undefined + authLoaded?: boolean | undefined + }>() .middleware(async ({ context, next }) => { // reuse the loaded auth value if it was already loaded - const auth = context.authLoaded - ? context.auth - : await loadAuth(context.headers) + const auth = context.authLoaded ? context.auth : await loadAuth(context.headers) return next({ context: { auth, authLoaded: true } }) }) @@ -44,12 +46,14 @@ import { call, os } from '@orpc/server' declare function loadAuth(headers: Headers): Promise<{ id: string } | undefined> const authProvider = os - .$context<{ headers: Headers, auth?: { id: string } | undefined, authLoaded?: boolean | undefined }>() + .$context<{ + headers: Headers + auth?: { id: string } | undefined + authLoaded?: boolean | undefined + }>() .middleware(async ({ context, next }) => { // reuse the loaded auth value if it was already loaded - const auth = context.authLoaded - ? context.auth - : await loadAuth(context.headers) + const auth = context.authLoaded ? context.auth : await loadAuth(context.headers) return next({ context: { auth, authLoaded: true } }) }) diff --git a/apps/content/docs/recipes/exceeds-the-maximum-length-problem.mdx b/apps/content/docs/recipes/exceeds-the-maximum-length-problem.mdx index ed7bfcad6e..5729b33b99 100644 --- a/apps/content/docs/recipes/exceeds-the-maximum-length-problem.mdx +++ b/apps/content/docs/recipes/exceeds-the-maximum-length-problem.mdx @@ -1,8 +1,8 @@ --- -title: "Exceeds the Maximum Length Problem" -description: "Fix the TypeScript error where an inferred router type exceeds the maximum length the compiler will serialize." +title: 'Exceeds the Maximum Length Problem' +description: 'Fix the TypeScript error where an inferred router type exceeds the maximum length the compiler will serialize.' sidebar: - label: "Exceeds the maximum length ..." + label: 'Exceeds the maximum length ...' --- 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. @@ -50,9 +50,9 @@ Use the [type](/docs/procedure#type-utility) utility if you just want to specify 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 = { /** ... */ } -export const planetRouter = { /** ... */ } -export const publicRouter = { /** ... */ } +export const userRouter = {/** ... */} +export const planetRouter = {/** ... */} +export const publicRouter = {/** ... */} ``` Then define the client type from those smaller exports: diff --git a/apps/content/docs/recipes/index.mdx b/apps/content/docs/recipes/index.mdx index 67d02bc4d9..0a2268287c 100644 --- a/apps/content/docs/recipes/index.mdx +++ b/apps/content/docs/recipes/index.mdx @@ -1,8 +1,8 @@ --- -title: "Recipes" -description: "Practical oRPC recipes for testing and mocking, validation, monorepos, publishing a typed client to npm, faster SSR, and fixing common TypeScript errors." +title: 'Recipes' +description: 'Practical oRPC recipes for testing and mocking, validation, monorepos, publishing a typed client to npm, faster SSR, and fixing common TypeScript errors.' seo: - title: "Recipes: Testing, Monorepos, SSR, and More" + title: 'Recipes: Testing, Monorepos, SSR, and More' sidebar: hidden: true --- diff --git a/apps/content/docs/recipes/monorepo-setup.mdx b/apps/content/docs/recipes/monorepo-setup.mdx index b7ca4dc39b..5adc9f7b29 100644 --- a/apps/content/docs/recipes/monorepo-setup.mdx +++ b/apps/content/docs/recipes/monorepo-setup.mdx @@ -1,6 +1,6 @@ --- -title: "Monorepo Setup" -description: "Set up a monorepo with oRPC using TypeScript project references to keep end-to-end type safety across interconnected apps and packages." +title: 'Monorepo Setup' +description: 'Set up a monorepo with oRPC using TypeScript project references to keep end-to-end type safety across interconnected apps and packages.' --- :::info[Playground] diff --git a/apps/content/docs/recipes/no-throw-literal.mdx b/apps/content/docs/recipes/no-throw-literal.mdx index e3a86fcf2a..d71567b824 100644 --- a/apps/content/docs/recipes/no-throw-literal.mdx +++ b/apps/content/docs/recipes/no-throw-literal.mdx @@ -1,6 +1,6 @@ --- -title: "No Throw Literal" -description: "Learn why oRPC recommends throwing only Error instances and how to customize this behavior with ThrowableError in the Registry." +title: 'No Throw Literal' +description: 'Learn why oRPC recommends throwing only Error instances and how to customize this behavior with ThrowableError in the Registry.' --- ```ts @@ -18,7 +18,8 @@ oRPC treats thrown `Error` instances as best practice by default, as recommended Customize oRPC's behavior by setting `ThrowableError` in the `Registry`: ```ts -declare module '@orpc/server' { // or '@orpc/contract', or '@orpc/client' +declare module '@orpc/server' { + // or '@orpc/contract', or '@orpc/client' interface Registry { ThrowableError: Error // [!code highlight] } @@ -41,8 +42,7 @@ if (!isSuccess) { } // handle other errors -} -else { +} else { // handle success } ``` diff --git a/apps/content/docs/recipes/optimizing-ssr.mdx b/apps/content/docs/recipes/optimizing-ssr.mdx index d8a9b399be..af4deb75fc 100644 --- a/apps/content/docs/recipes/optimizing-ssr.mdx +++ b/apps/content/docs/recipes/optimizing-ssr.mdx @@ -1,8 +1,8 @@ --- -title: "Optimizing Server-Side Rendering (SSR) for Fullstack Frameworks" -description: "Optimize server-side rendering with oRPC in Next.js, Nuxt, and SvelteKit by calling API logic directly and avoiding extra HTTP requests." +title: 'Optimizing Server-Side Rendering (SSR) for Fullstack Frameworks' +description: 'Optimize server-side rendering with oRPC in Next.js, Nuxt, and SvelteKit by calling API logic directly and avoiding extra HTTP requests.' sidebar: - label: "Optimizing SSR" + label: 'Optimizing SSR' --- ## The Problem with Standard SSR Data Fetching @@ -83,7 +83,8 @@ const internalLink = new RPCLink({ // Use a fetch handler here const { response } = await handler.handle(request, { - context: { // provide initial context if needed + context: { + // provide initial context if needed headers: await headers(), }, }) @@ -139,7 +140,7 @@ export default async function PlanetListPage() { return (
- {planets.map(planet => ( + {planets.map((planet) => (
{planet.name}
))}
diff --git a/apps/content/docs/recipes/publish-client-to-npm.mdx b/apps/content/docs/recipes/publish-client-to-npm.mdx index c4d8a22d46..c097590640 100644 --- a/apps/content/docs/recipes/publish-client-to-npm.mdx +++ b/apps/content/docs/recipes/publish-client-to-npm.mdx @@ -1,6 +1,6 @@ --- -title: "Publish Client to NPM" -description: "Learn how to build and publish your oRPC client to NPM with pnpm and tsdown, so users can consume your APIs as a fully typed SDK." +title: 'Publish Client to NPM' +description: 'Learn how to build and publish your oRPC client to NPM with pnpm and tsdown, so users can consume your APIs as a fully typed SDK.' --- :::info @@ -30,7 +30,7 @@ export function createMyApi(apiKey: string): RouterContractClient { - await expect( - call(router.planet.list, { page: 1, size: 10 }) - ).resolves.toEqual([ + await expect(call(router.planet.list, { page: 1, size: 10 })).resolves.toEqual([ { id: '1', name: 'Earth' }, { id: '2', name: 'Mars' }, ]) @@ -56,10 +54,8 @@ import { RPCHandler } from '@orpc/server/fetch' const mock = createHTTPUtils(router, { prefix: '/rpc', - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) -const handlers = [ - mock.planet.list.handler(() => []), -] +const handlers = [mock.planet.list.handler(() => [])] ``` diff --git a/apps/content/docs/recipes/validation-customization.mdx b/apps/content/docs/recipes/validation-customization.mdx index 8f2f541cb0..f70c0aea66 100644 --- a/apps/content/docs/recipes/validation-customization.mdx +++ b/apps/content/docs/recipes/validation-customization.mdx @@ -1,6 +1,6 @@ --- -title: "Validation Customization" -description: "Learn how to customize validation in oRPC, including disabling runtime validation and shaping custom validation errors." +title: 'Validation Customization' +description: 'Learn how to customize validation in oRPC, including disabling runtime validation and shaping custom validation errors.' --- ## Disable Validation @@ -29,7 +29,7 @@ const base = os.$config({ * * @default false */ - disableOutputValidation: true + disableOutputValidation: true, }) ``` @@ -39,7 +39,7 @@ For example, the following schema accepts a `number` but returns a `string`: ```ts z.object({ - value: z.number().transform(value => String(value)), + value: z.number().transform((value) => String(value)), }) ``` @@ -62,12 +62,11 @@ const handler = new RPCHandler(router, { async ({ next }) => { try { return await next() - } - catch (error) { + } catch (error) { if ( - error instanceof ORPCError - && error.code === 'BAD_REQUEST' - && error.cause instanceof ValidationError + 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[]) @@ -80,9 +79,9 @@ const handler = new RPCHandler(router, { } if ( - error instanceof ORPCError - && error.code === 'INTERNAL_SERVER_ERROR' - && error.cause instanceof ValidationError + 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', { @@ -118,35 +117,37 @@ const base = os.errors({ }, }) -const example = base - .input(z.object({ id: z.uuid() })) - .handler(() => { /** do something */ }) - -const handler = new RPCHandler({ example }, { - clientInterceptors: [ - 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[]) +const example = base.input(z.object({ id: z.uuid() })).handler(() => { + /** do something */ +}) - throw new ORPCError('INPUT_VALIDATION_FAILED', { - message: z.prettifyError(zodError), - data: z.flattenError(zodError), - cause: error, - }) +const handler = new RPCHandler( + { example }, + { + clientInterceptors: [ + 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 } - - throw error - } - }, - ], -}) + }, + ], + }, +) ``` diff --git a/apps/content/docs/requirements.mdx b/apps/content/docs/requirements.mdx index 227709a45b..4a490cd61f 100644 --- a/apps/content/docs/requirements.mdx +++ b/apps/content/docs/requirements.mdx @@ -1,8 +1,8 @@ --- -title: "Runtime Requirements" -description: "The ES2022 features and standard Web APIs oRPC needs, with minimum versions for Node.js, Deno, Bun, Cloudflare Workers, browsers, Android, and iOS." +title: 'Runtime Requirements' +description: 'The ES2022 features and standard Web APIs oRPC needs, with minimum versions for Node.js, Deno, Bun, Cloudflare Workers, browsers, Android, and iOS.' sidebar: - label: "Requirements" + label: 'Requirements' icon: cpu --- @@ -10,17 +10,17 @@ oRPC's core abstractions are written against the JavaScript language and standar ## Overview -| Runtime | Minimum | Recommended | -| --- | --- | --- | -| Node.js | 18 | 22+ | -| Deno | 1.14 | 2.0+ | -| Bun | 1.0 | 1.3.3+ | -| Cloudflare Workers | — | — | -| Chrome | 94 | — | -| Firefox | 93 | — | -| Safari | 16.4 | — | -| Android WebView | 94 | — | -| iOS WebView (WKWebView) | 16.4 | — | +| Runtime | Minimum | Recommended | +| ----------------------- | ------- | ----------- | +| Node.js | 18 | 22+ | +| Deno | 1.14 | 2.0+ | +| Bun | 1.0 | 1.3.3+ | +| Cloudflare Workers | — | — | +| Chrome | 94 | — | +| Firefox | 93 | — | +| Safari | 16.4 | — | +| Android WebView | 94 | — | +| iOS WebView (WKWebView) | 16.4 | — | Browsers update themselves, so there is nothing to recommend beyond the minimum. Edge and Chrome on Android match Chrome, Samsung Internet needs 17.0, and Safari on iOS matches iOS WebView. Cloudflare Workers tracks current V8 and ships every Web API oRPC uses; use the [Cloudflare Workers Adapter](/docs/adapters/cloudflare-workers) there. [React Native](#react-native) is not a web view and needs a polyfill. @@ -56,32 +56,32 @@ oRPC packages declare their entry points only through the `exports` field. Parce Beyond ES2022, oRPC uses the APIs below. Every one is [Baseline Widely available](https://web.dev/baseline), meaning it landed in every major browser at least 30 months ago. The dates are when that happened. -| API | Needed when | Across browsers since | Available from | -| --- | --- | --- | --- | -| [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) | Always | Jan 2019 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 43 · Firefox 65 · Safari 10.1 · Android WebView 43 · iOS WebView 10.3 | -| [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) | Always | Jul 2015 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 5 · Firefox 4 · Safari 6 · Android WebView 4.4 · iOS WebView 6 | -| [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) | Always | Jul 2015 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 5 · Firefox 4 · Safari 5 · Android WebView 3 · iOS WebView 5 | -| [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) | Always | Jul 2015 | Node 10 · Deno 1.0 · Bun 1.0 · Chrome 32 · Firefox 19 · Safari 7 · Android WebView 4.4 · iOS WebView 7 | -| [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) | Always | Apr 2018 | Node 10 · Deno 1.0 · Bun 1.0 · Chrome 49 · Firefox 29 · Safari 10.1 · Android WebView 49 · iOS WebView 10.3 | -| [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch) | [Fetch adapter](/docs/adapters/fetch-api) | Mar 2017 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 42 · Firefox 39 · Safari 10.1 · Android WebView 42 · iOS WebView 10.3 | -| [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | [Fetch adapter](/docs/adapters/fetch-api) | Mar 2017 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 42 · Firefox 39 · Safari 10.1 · Android WebView 42 · iOS WebView 10.3 | -| [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) | [Fetch adapter](/docs/adapters/fetch-api) | Mar 2017 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 42 · Firefox 39 · Safari 10.1 · Android WebView 42 · iOS WebView 10.3 | -| [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) | [Fetch adapter](/docs/adapters/fetch-api), plus any adapter that encodes or decodes `FormData` | Mar 2017 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 42 · Firefox 39 · Safari 10.1 · Android WebView 42 · iOS WebView 10.3 | -| [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) | [AWS Lambda](/docs/adapters/aws-lambda), [WebSocket](/docs/adapters/websocket), and [Message Port](/docs/adapters/message-port) adapters, the [Batch plugin](/docs/plugins/batch), and the [Encryption](/docs/helpers/encryption) and [Signing](/docs/helpers/signing) helpers | Jan 2020 | Node 11 · Deno 1.0 · Bun 1.0 · Chrome 38 · Firefox 18 · Safari 10.1 · Android WebView 38 · iOS WebView 10.3 | -| [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder) | [Node.js](/docs/adapters/node-http), [Fastify](/docs/adapters/fastify), [AWS Lambda](/docs/adapters/aws-lambda), [WebSocket](/docs/adapters/websocket), and [Message Port](/docs/adapters/message-port) adapters, and the [Encryption](/docs/helpers/encryption) helper | Jan 2020 | Node 11 · Deno 1.0 · Bun 1.0 · Chrome 38 · Firefox 19 · Safari 10.1 · Android WebView 38 · iOS WebView 10.3 | -| [`TransformStream`](https://developer.mozilla.org/en-US/docs/Web/API/TransformStream) | [AsyncIteratorObject](/docs/async-iterator-object) over HTTP, and the [Request Limit](/docs/plugins/request-limit) and [Tmp File Upload](/docs/plugins/tmp-file-upload) plugins | Jun 2022 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 67 · Firefox 102 · Safari 14.1 · Android WebView 67 · iOS WebView 14.5 | -| [`TextEncoderStream`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoderStream) / [`TextDecoderStream`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoderStream) | [AsyncIteratorObject](/docs/async-iterator-object) over HTTP | Sep 2022 | Node 18 · Deno 1.11 · Bun 1.1.23 · Chrome 71 · Firefox 105 · Safari 14.1 · Android WebView 71 · iOS WebView 14.5 | -| [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) | [Node.js](/docs/adapters/node-http), [Fastify](/docs/adapters/fastify), [AWS Lambda](/docs/adapters/aws-lambda), [WebSocket](/docs/adapters/websocket), and [Message Port](/docs/adapters/message-port) adapters, and the [Batch](/docs/plugins/batch), [Timeout](/docs/plugins/timeout), [Signal Sync](/docs/plugins/signal-sync), and [Dedupe](/docs/plugins/dedupe) plugins | Mar 2019 | Node 14.17 · Deno 1.0 · Bun 1.0 · Chrome 66 · Firefox 57 · Safari 12.1 · Android WebView 66 · iOS WebView 12.2 | -| [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) | [WebSocket adapter](/docs/adapters/websocket), unless you supply your own implementation such as [`ws`](https://github.com/websockets/ws) | Jul 2015 | Node 22 · Deno 1.4 · Bun 1.0 · Chrome 5 · Firefox 11 · Safari 5 · Android WebView 4.4 · iOS WebView 4.2 | -| [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort) | [Message Port adapter](/docs/adapters/message-port) | Sep 2015 | Node 15 · Deno 1.12 · Bun 1.0 · Chrome 2 · Firefox 41 · Safari 5 · Android WebView 4.4 · iOS WebView 4.2 | -| [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) | Sending or receiving a [`Blob` or `File`](/docs/binary-data#file-and-blob) | Jul 2015 | Node 20 · Deno 1.0 · Bun 1.0 · Chrome 13 · Firefox 7 · Safari 4 · Android WebView 4.4 · iOS WebView 3.2 | -| [`CompressionStream`](https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream) | The sending side of the [Request](/docs/plugins/request-compression) and [Response](/docs/plugins/response-compression) Compression plugins | May 2023 | Node 18 · Deno 1.19 · Bun 1.3.3 · Chrome 80 · Firefox 113 · Safari 16.4 · Android WebView 80 · iOS WebView 16.4 | -| [`DecompressionStream`](https://developer.mozilla.org/en-US/docs/Web/API/DecompressionStream) | The receiving side of the [Request](/docs/plugins/request-compression) and [Response](/docs/plugins/response-compression) Compression plugins | May 2023 | Node 18 · Deno 1.19 · Bun 1.3.3 · Chrome 80 · Firefox 113 · Safari 16.4 · Android WebView 80 · iOS WebView 16.4 | -| [`crypto.subtle`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle) (PBKDF2 `deriveKey`, AES-GCM `encrypt`) | [Encryption](/docs/helpers/encryption) and [Signing](/docs/helpers/signing) helpers | Sep 2017 | Node 19 · Deno 1.18 · Bun 1.0 · Chrome 41 · Firefox 34 · Safari 11 · Android WebView 41 · iOS WebView 11 | -| [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | [Encryption](/docs/helpers/encryption) and [Signing](/docs/helpers/signing) helpers | Jul 2015 | Node 19 · Deno 1.0 · Bun 1.0 · Chrome 11 · Firefox 21 · Safari 5 · Android WebView 4.4 · iOS WebView 5 | -| [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) | Default request ID in the [Pino integration](/docs/integrations/pino), unless you supply `generateRequestId` | Mar 2022 | Node 19 · Deno 1.11 · Bun 1.0 · Chrome 92 · Firefox 95 · Safari 15.4 · Android WebView 92 · iOS WebView 15.4 | -| [`atob()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/atob) | [Base64url](/docs/helpers/base64url) helper | Jul 2015 | Node 16 · Deno 1.0 · Bun 1.0 · Chrome 4 · Firefox 1 · Safari 3 · Android WebView 4.4 · iOS WebView 1 | -| [`btoa()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa) | [Base64url](/docs/helpers/base64url) helper | Jul 2015 | Node 16 · Deno 1.0 · Bun 1.0 · Chrome 4 · Firefox 1 · Safari 3 · Android WebView 4.4 · iOS WebView 1 | +| API | Needed when | Across browsers since | Available from | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------- | ---------------------------------------------------------------------------------------------------------------- | +| [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) | Always | Jan 2019 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 43 · Firefox 65 · Safari 10.1 · Android WebView 43 · iOS WebView 10.3 | +| [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) | Always | Jul 2015 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 5 · Firefox 4 · Safari 6 · Android WebView 4.4 · iOS WebView 6 | +| [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) | Always | Jul 2015 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 5 · Firefox 4 · Safari 5 · Android WebView 3 · iOS WebView 5 | +| [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) | Always | Jul 2015 | Node 10 · Deno 1.0 · Bun 1.0 · Chrome 32 · Firefox 19 · Safari 7 · Android WebView 4.4 · iOS WebView 7 | +| [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) | Always | Apr 2018 | Node 10 · Deno 1.0 · Bun 1.0 · Chrome 49 · Firefox 29 · Safari 10.1 · Android WebView 49 · iOS WebView 10.3 | +| [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch) | [Fetch adapter](/docs/adapters/fetch-api) | Mar 2017 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 42 · Firefox 39 · Safari 10.1 · Android WebView 42 · iOS WebView 10.3 | +| [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | [Fetch adapter](/docs/adapters/fetch-api) | Mar 2017 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 42 · Firefox 39 · Safari 10.1 · Android WebView 42 · iOS WebView 10.3 | +| [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) | [Fetch adapter](/docs/adapters/fetch-api) | Mar 2017 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 42 · Firefox 39 · Safari 10.1 · Android WebView 42 · iOS WebView 10.3 | +| [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) | [Fetch adapter](/docs/adapters/fetch-api), plus any adapter that encodes or decodes `FormData` | Mar 2017 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 42 · Firefox 39 · Safari 10.1 · Android WebView 42 · iOS WebView 10.3 | +| [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) | [AWS Lambda](/docs/adapters/aws-lambda), [WebSocket](/docs/adapters/websocket), and [Message Port](/docs/adapters/message-port) adapters, the [Batch plugin](/docs/plugins/batch), and the [Encryption](/docs/helpers/encryption) and [Signing](/docs/helpers/signing) helpers | Jan 2020 | Node 11 · Deno 1.0 · Bun 1.0 · Chrome 38 · Firefox 18 · Safari 10.1 · Android WebView 38 · iOS WebView 10.3 | +| [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder) | [Node.js](/docs/adapters/node-http), [Fastify](/docs/adapters/fastify), [AWS Lambda](/docs/adapters/aws-lambda), [WebSocket](/docs/adapters/websocket), and [Message Port](/docs/adapters/message-port) adapters, and the [Encryption](/docs/helpers/encryption) helper | Jan 2020 | Node 11 · Deno 1.0 · Bun 1.0 · Chrome 38 · Firefox 19 · Safari 10.1 · Android WebView 38 · iOS WebView 10.3 | +| [`TransformStream`](https://developer.mozilla.org/en-US/docs/Web/API/TransformStream) | [AsyncIteratorObject](/docs/async-iterator-object) over HTTP, and the [Request Limit](/docs/plugins/request-limit) and [Tmp File Upload](/docs/plugins/tmp-file-upload) plugins | Jun 2022 | Node 18 · Deno 1.0 · Bun 1.0 · Chrome 67 · Firefox 102 · Safari 14.1 · Android WebView 67 · iOS WebView 14.5 | +| [`TextEncoderStream`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoderStream) / [`TextDecoderStream`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoderStream) | [AsyncIteratorObject](/docs/async-iterator-object) over HTTP | Sep 2022 | Node 18 · Deno 1.11 · Bun 1.1.23 · Chrome 71 · Firefox 105 · Safari 14.1 · Android WebView 71 · iOS WebView 14.5 | +| [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) | [Node.js](/docs/adapters/node-http), [Fastify](/docs/adapters/fastify), [AWS Lambda](/docs/adapters/aws-lambda), [WebSocket](/docs/adapters/websocket), and [Message Port](/docs/adapters/message-port) adapters, and the [Batch](/docs/plugins/batch), [Timeout](/docs/plugins/timeout), [Signal Sync](/docs/plugins/signal-sync), and [Dedupe](/docs/plugins/dedupe) plugins | Mar 2019 | Node 14.17 · Deno 1.0 · Bun 1.0 · Chrome 66 · Firefox 57 · Safari 12.1 · Android WebView 66 · iOS WebView 12.2 | +| [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) | [WebSocket adapter](/docs/adapters/websocket), unless you supply your own implementation such as [`ws`](https://github.com/websockets/ws) | Jul 2015 | Node 22 · Deno 1.4 · Bun 1.0 · Chrome 5 · Firefox 11 · Safari 5 · Android WebView 4.4 · iOS WebView 4.2 | +| [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort) | [Message Port adapter](/docs/adapters/message-port) | Sep 2015 | Node 15 · Deno 1.12 · Bun 1.0 · Chrome 2 · Firefox 41 · Safari 5 · Android WebView 4.4 · iOS WebView 4.2 | +| [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) | Sending or receiving a [`Blob` or `File`](/docs/binary-data#file-and-blob) | Jul 2015 | Node 20 · Deno 1.0 · Bun 1.0 · Chrome 13 · Firefox 7 · Safari 4 · Android WebView 4.4 · iOS WebView 3.2 | +| [`CompressionStream`](https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream) | The sending side of the [Request](/docs/plugins/request-compression) and [Response](/docs/plugins/response-compression) Compression plugins | May 2023 | Node 18 · Deno 1.19 · Bun 1.3.3 · Chrome 80 · Firefox 113 · Safari 16.4 · Android WebView 80 · iOS WebView 16.4 | +| [`DecompressionStream`](https://developer.mozilla.org/en-US/docs/Web/API/DecompressionStream) | The receiving side of the [Request](/docs/plugins/request-compression) and [Response](/docs/plugins/response-compression) Compression plugins | May 2023 | Node 18 · Deno 1.19 · Bun 1.3.3 · Chrome 80 · Firefox 113 · Safari 16.4 · Android WebView 80 · iOS WebView 16.4 | +| [`crypto.subtle`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle) (PBKDF2 `deriveKey`, AES-GCM `encrypt`) | [Encryption](/docs/helpers/encryption) and [Signing](/docs/helpers/signing) helpers | Sep 2017 | Node 19 · Deno 1.18 · Bun 1.0 · Chrome 41 · Firefox 34 · Safari 11 · Android WebView 41 · iOS WebView 11 | +| [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | [Encryption](/docs/helpers/encryption) and [Signing](/docs/helpers/signing) helpers | Jul 2015 | Node 19 · Deno 1.0 · Bun 1.0 · Chrome 11 · Firefox 21 · Safari 5 · Android WebView 4.4 · iOS WebView 5 | +| [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) | Default request ID in the [Pino integration](/docs/integrations/pino), unless you supply `generateRequestId` | Mar 2022 | Node 19 · Deno 1.11 · Bun 1.0 · Chrome 92 · Firefox 95 · Safari 15.4 · Android WebView 92 · iOS WebView 15.4 | +| [`atob()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/atob) | [Base64url](/docs/helpers/base64url) helper | Jul 2015 | Node 16 · Deno 1.0 · Bun 1.0 · Chrome 4 · Firefox 1 · Safari 3 · Android WebView 4.4 · iOS WebView 1 | +| [`btoa()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa) | [Base64url](/docs/helpers/base64url) helper | Jul 2015 | Node 16 · Deno 1.0 · Bun 1.0 · Chrome 4 · Firefox 1 · Safari 3 · Android WebView 4.4 · iOS WebView 1 | Version numbers come from [MDN's browser compatibility data](https://github.com/mdn/browser-compat-data). A few rows need more explanation: diff --git a/apps/content/docs/router.mdx b/apps/content/docs/router.mdx index 25b6ae1973..e94a0fc8d5 100644 --- a/apps/content/docs/router.mdx +++ b/apps/content/docs/router.mdx @@ -1,6 +1,6 @@ --- -title: "Router" -description: "Organize oRPC procedures into nestable routers, extend them with shared middleware, lazy-load routes, and infer their types." +title: 'Router' +description: 'Organize oRPC procedures into nestable routers, extend them with shared middleware, lazy-load routes, and infer their types.' --- :::info @@ -20,7 +20,7 @@ const pong = os.handler(async () => 'pong') export const router = { ping, pong, - nested: { ping, pong } + nested: { ping, pong }, } ``` @@ -39,7 +39,7 @@ const router = os.use(requiredAuth).meta(requireAuthMeta).router({ nested: { ping, pong, - } + }, }) ``` @@ -67,7 +67,7 @@ Routers can also be lazy-loaded. This is useful for code splitting and can impro const router = { ping, pong, - planet: os.lazy(() => import('./planet')) + planet: os.lazy(() => import('./planet')), } ``` diff --git a/apps/content/docs/rpc/adapters.mdx b/apps/content/docs/rpc/adapters.mdx index fad8bee856..06afbd6c26 100644 --- a/apps/content/docs/rpc/adapters.mdx +++ b/apps/content/docs/rpc/adapters.mdx @@ -1,6 +1,6 @@ --- -title: "RPC Adapters" -description: "The adapters that run RPC Handler and RPC Link over HTTP, WebSocket, and message ports, and where to import each one." +title: 'RPC Adapters' +description: 'The adapters that run RPC Handler and RPC Link over HTTP, WebSocket, and message ports, and where to import each one.' --- RPC runs over HTTP, WebSocket, and message ports. Pick the adapter for your transport, then import [RPC Handler](/docs/rpc/handler) or [RPC Link](/docs/rpc/link) from it. The router stays the same. diff --git a/apps/content/docs/rpc/handler.mdx b/apps/content/docs/rpc/handler.mdx index 9129b69294..674ca01e49 100644 --- a/apps/content/docs/rpc/handler.mdx +++ b/apps/content/docs/rpc/handler.mdx @@ -1,6 +1,6 @@ --- -title: "RPC Handler" -description: "Use RPCHandler to serve procedures over the RPC protocol, with HTTP method control, interceptors, plugins, custom serializers, and error responses." +title: 'RPC Handler' +description: 'Use RPCHandler to serve procedures over the RPC protocol, with HTTP method control, interceptors, plugins, custom serializers, and error responses.' --- ## Overview @@ -15,19 +15,15 @@ const handler = new RPCHandler(router, { try { return await next() - } - catch (err) { + } catch (err) { console.error(`${path.join('.')}:`, err) throw err - } - finally { + } finally { console.timeEnd(path.join('.')) } - } - ], - plugins: [ - new CORSHandlerPlugin() + }, ], + plugins: [new CORSHandlerPlugin()], }) ``` @@ -38,7 +34,7 @@ The actual usage of `RPCHandler` depends on the [adapter](/docs/rpc/adapters) yo export async function fetch(request: Request) { const { matched, response } = await handler.handle(request, { prefix: '/rpc', - context: {} // <- provide initial context if needed + context: {}, // <- provide initial context if needed }) if (matched) { @@ -129,15 +125,18 @@ const listPlanets = os .meta(openapi({ method: 'GET', path: '/planets' })) .handler(() => ['Earth', 'Mars']) -const handler = new RPCHandler({ listPlanets }, { - allowMethods: (method, procedure, path) => { - if (method === 'GET' && getOpenAPIMeta(procedure)?.method === 'GET') { - return true - } +const handler = new RPCHandler( + { listPlanets }, + { + allowMethods: (method, procedure, path) => { + if (method === 'GET' && getOpenAPIMeta(procedure)?.method === 'GET') { + return true + } - return RPC_DEFAULT_ALLOW_METHODS.includes(method) + return RPC_DEFAULT_ALLOW_METHODS.includes(method) + }, }, -}) +) ``` ## Interceptors @@ -178,8 +177,7 @@ const handler = new RPCHandler(router, { try { const response = await next() return response - } - catch (err) { + } catch (err) { if (err instanceof CustomError) { throw new ORPCError('CUSTOM_ERROR', { message: err.message, cause: err }) } @@ -193,12 +191,10 @@ const handler = new RPCHandler(router, { try { const response = await next() return response - } - catch (err) { + } catch (err) { console.error(`${path.join('.')}:`, err) throw err - } - finally { + } finally { console.timeEnd(path.join('.')) } }, @@ -227,9 +223,7 @@ Plugins package reusable interceptors. For example, [CORS Plugin](/docs/plugins/ ```ts const handler = new RPCHandler(router, { - plugins: [ - new CORSHandlerPlugin() - ], + plugins: [new CORSHandlerPlugin()], }) ``` diff --git a/apps/content/docs/rpc/index.mdx b/apps/content/docs/rpc/index.mdx index afb7c406af..15ee81684f 100644 --- a/apps/content/docs/rpc/index.mdx +++ b/apps/content/docs/rpc/index.mdx @@ -1,8 +1,8 @@ --- -title: "RPC" +title: 'RPC' description: "How oRPC's RPC protocol connects oRPC clients and servers, with a serializer that keeps Date, BigInt, Map, Set, files, and streams intact across the wire." seo: - title: "RPC: Protocol, Serializer, Handler, and Link" + title: 'RPC: Protocol, Serializer, Handler, and Link' sidebar: hidden: true --- diff --git a/apps/content/docs/rpc/link.mdx b/apps/content/docs/rpc/link.mdx index cf817f80b5..4f84e1f4dd 100644 --- a/apps/content/docs/rpc/link.mdx +++ b/apps/content/docs/rpc/link.mdx @@ -1,6 +1,6 @@ --- -title: "RPC Link" -description: "Use RPCLink to call RPC Handler servers or any server implementing the RPC protocol, with headers, interceptors, plugins, and custom serializers." +title: 'RPC Link' +description: 'Use RPCLink to call RPC Handler servers or any server implementing the RPC protocol, with headers, interceptors, plugins, and custom serializers.' --- ## Overview @@ -20,20 +20,17 @@ const link = new RPCLink({ try { return await next() - } - catch (err) { + } catch (err) { console.error(`${path.join('.')}:`, err) throw err - } - finally { + } finally { console.timeEnd(path.join('.')) } }, ], - plugins: [ - new RetryAfterLinkPlugin(), - ], - fetch: (url, init) => { // <- only available in fetch adapter + plugins: [new RetryAfterLinkPlugin()], + fetch: (url, init) => { + // <- only available in fetch adapter return globalThis.fetch(url, { ...init, credentials: 'include', // Include cookies on cross-origin requests @@ -168,12 +165,10 @@ const link = new RPCLink({ try { const output = await next() return output - } - catch (err) { + } catch (err) { console.error(`${path.join('.')}:`, err) throw err - } - finally { + } finally { console.timeEnd(path.join('.')) } }, @@ -212,9 +207,7 @@ Plugins package reusable interceptors. For example, [Retry After Plugin](/docs/p ```ts const link = new RPCLink({ - plugins: [ - new RetryAfterLinkPlugin(), - ], + plugins: [new RetryAfterLinkPlugin()], }) ``` @@ -278,7 +271,11 @@ const link = new RPCLink({ interceptors: [ onError((error) => { if (error instanceof ORPCError && error.cause instanceof ResponseError) { - console.error('Request failed:', error.cause.response.status, error.cause.response.headers['x-request-id']) + console.error( + 'Request failed:', + error.cause.response.status, + error.cause.response.headers['x-request-id'], + ) } }), ], diff --git a/apps/content/docs/rpc/meta.ts b/apps/content/docs/rpc/meta.ts index 215aba718b..644f2d0b05 100644 --- a/apps/content/docs/rpc/meta.ts +++ b/apps/content/docs/rpc/meta.ts @@ -2,11 +2,5 @@ import { defineMeta } from 'blume' export default defineMeta({ title: 'RPC', - pages: [ - 'protocol', - 'serializer', - 'handler', - 'link', - 'adapters', - ], + pages: ['protocol', 'serializer', 'handler', 'link', 'adapters'], }) diff --git a/apps/content/docs/rpc/protocol.mdx b/apps/content/docs/rpc/protocol.mdx index b22ad3f99d..466131a58b 100644 --- a/apps/content/docs/rpc/protocol.mdx +++ b/apps/content/docs/rpc/protocol.mdx @@ -1,6 +1,6 @@ --- -title: "RPC Protocol" -description: "Learn how the RPC protocol routes procedure calls, encodes input and output beyond plain JSON, and formats success and error responses." +title: 'RPC Protocol' +description: 'Learn how the RPC protocol routes procedure calls, encodes input and output beyond plain JSON, and formats success and error responses.' --- The RPC protocol is a lightweight protocol for remote procedure calls, used by [RPC Handler](/docs/rpc/handler) and [RPC Link](/docs/rpc/link). @@ -34,8 +34,8 @@ This calls the `planet.create` procedure when `/rpc` is the prefix: ```ts const router = { planet: { - create: os.handler(() => {}) // [!code highlight] - } + create: os.handler(() => {}), // [!code highlight] + }, } ``` @@ -52,13 +52,16 @@ Request payloads depend on the serializer and are not plain JSON. Learn more in ```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']] -})) +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) ``` @@ -82,21 +85,24 @@ curl -X POST https://example.com/rpc/planet/create \ ```ts const form = new FormData() -form.set('data', JSON.stringify({ - json: { - name: 'Earth', - thumbnail: {}, - images: [{}], - }, - maps: [['thumbnail'], ['images', 0]] -})) +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 + body: form, }) ``` diff --git a/apps/content/docs/rpc/serializer.mdx b/apps/content/docs/rpc/serializer.mdx index ae4567fce8..c3b6f160ff 100644 --- a/apps/content/docs/rpc/serializer.mdx +++ b/apps/content/docs/rpc/serializer.mdx @@ -1,6 +1,6 @@ --- -title: "RPC Serializer" -description: "Learn how RPC Serializers encode data between client and server, supporting types beyond plain JSON such as Date, BigInt, Set, Map, and custom classes." +title: 'RPC Serializer' +description: 'Learn how RPC Serializers encode data between client and server, supporting types beyond plain JSON such as Date, BigInt, Set, Map, and custom classes.' --- ## Supported Data Types @@ -59,18 +59,20 @@ const PersonSchema = z.object({ name: z.string(), age: z.number() }) const serializer = new RPCSerializer({ handlers: { - person: { // <- add support for Person - condition: v => v instanceof Person, + person: { + // <- add support for Person + condition: (v) => v instanceof Person, serialize: (v: Person) => ({ name: v.name, age: v.age }), deserialize: (v) => { const { name, age } = PersonSchema.parse(v) return new Person(name, age) }, }, - date: { // <- replace the default Date handler - condition: v => v instanceof Date, + date: { + // <- replace the default Date handler + condition: (v) => v instanceof Date, serialize: (v: Date) => v.getTime(), - deserialize: v => new Date(z.number().parse(v)), + deserialize: (v) => new Date(z.number().parse(v)), }, }, }) @@ -108,9 +110,16 @@ import { RPCSerializer } from '@orpc/client' const serializer = new RPCSerializer({ handlers: { bigint: { - condition: v => typeof v === 'bigint', + condition: (v) => typeof v === 'bigint', serialize: (v: bigint) => v.toString(), - deserialize: v => BigInt(z.string().max(10_000).regex(/^-?\d+$/).parse(v)), + deserialize: (v) => + BigInt( + z + .string() + .max(10_000) + .regex(/^-?\d+$/) + .parse(v), + ), }, }, }) @@ -163,9 +172,7 @@ In most cases, serialized data includes two optional fields: `json` and `meta`. "age": 30, "createdAt": "2024-01-01T00:00:00.000Z" }, - "meta": [ - ["date", "createdAt"] - ] + "meta": [["date", "createdAt"]] } ``` @@ -182,16 +189,19 @@ For example, `[['thumbnail'], ['images', 0]]` means the first file part correspo ```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( + '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' })) diff --git a/apps/content/experts/data.ts b/apps/content/experts/data.ts index c9acd2fb5b..7d691aaa14 100644 --- a/apps/content/experts/data.ts +++ b/apps/content/experts/data.ts @@ -53,7 +53,7 @@ export function joinHref(): string { /** Reports name people, so they go to a private inbox rather than GitHub issues. */ export function feedbackHref(): string { return mailto('Feedback on an oRPC expert', [ - 'Expert\'s name: ', + "Expert's name: ", 'When you worked together: ', 'What happened: ', ]) diff --git a/apps/content/package.json b/apps/content/package.json index 0c79f18417..8d0a077e41 100644 --- a/apps/content/package.json +++ b/apps/content/package.json @@ -1,7 +1,7 @@ { "name": "@orpc/content", - "type": "module", "private": true, + "type": "module", "scripts": { "dev": "blume dev", "build": "blume build", diff --git a/apps/content/pages/_home/stats.ts b/apps/content/pages/_home/stats.ts index b8965f6c65..e5f2cb4647 100644 --- a/apps/content/pages/_home/stats.ts +++ b/apps/content/pages/_home/stats.ts @@ -43,8 +43,7 @@ async function load(url: string): Promise { if (response.ok) { return await response.json() } - } - catch { + } catch { // Fall through to the retry. } @@ -73,17 +72,17 @@ function readJson(url: string): Promise { } async function monthlyDownloads(): Promise { - const data = await readJson( + const data = (await readJson( `https://api.npmjs.org/downloads/point/last-month/${NPM_PACKAGE}`, - ) as { downloads?: unknown } | null + )) as { downloads?: unknown } | null return typeof data?.downloads === 'number' ? compact.format(data.downloads) : null } async function coverage(): Promise { - const data = await readJson( - 'https://codecov.io/api/v2/github/middleapi/repos/orpc/', - ) as { totals?: { coverage?: unknown } } | null + const data = (await readJson('https://codecov.io/api/v2/github/middleapi/repos/orpc/')) as { + totals?: { coverage?: unknown } + } | null const value = data?.totals?.coverage @@ -101,13 +100,11 @@ async function coverage(): Promise { * differently-rounded versions of the same number. */ export async function githubStars(): Promise { - const data = await readJson( - 'https://api.github.com/repos/middleapi/orpc', - ) as { stargazers_count?: unknown } | null + const data = (await readJson('https://api.github.com/repos/middleapi/orpc')) as { + stargazers_count?: unknown + } | null - return typeof data?.stargazers_count === 'number' - ? compact.format(data.stargazers_count) - : null + return typeof data?.stargazers_count === 'number' ? compact.format(data.stargazers_count) : null } export async function landingStats(): Promise { @@ -119,25 +116,31 @@ export async function landingStats(): Promise { return [ ...(downloads - ? [{ - value: downloads, - label: 'Downloads each month', - href: `https://www.npmjs.com/package/${NPM_PACKAGE}`, - }] + ? [ + { + value: downloads, + label: 'Downloads each month', + href: `https://www.npmjs.com/package/${NPM_PACKAGE}`, + }, + ] : []), ...(covered - ? [{ - value: covered, - label: 'Test coverage', - href: 'https://codecov.io/gh/middleapi/orpc', - }] + ? [ + { + value: covered, + label: 'Test coverage', + href: 'https://codecov.io/gh/middleapi/orpc', + }, + ] : []), ...(starred - ? [{ - value: starred, - label: 'GitHub stars', - href: 'https://github.com/middleapi/orpc', - }] + ? [ + { + value: starred, + label: 'GitHub stars', + href: 'https://github.com/middleapi/orpc', + }, + ] : []), { // Bundle size lives in the comparison section instead, where it has diff --git a/apps/content/pages/_sponsors/Stats.astro b/apps/content/pages/_sponsors/Stats.astro index 2d5f939108..c678039a37 100644 --- a/apps/content/pages/_sponsors/Stats.astro +++ b/apps/content/pages/_sponsors/Stats.astro @@ -10,7 +10,7 @@ interface Props { const { tiers, past } = Astro.props const active = tiers.flatMap(tier => tier.sponsors) -const amounts = active.map(sponsor => sponsor.amount).sort((a, b) => a - b) +const amounts = active.map(sponsor => sponsor.amount).toSorted((a, b) => a - b) const middle = Math.floor(amounts.length / 2) // The median needs at least one active sponsor. @@ -26,7 +26,7 @@ const stats = active.length === 0 }, { value: active.length + past.length, - label: `Sponsors since ${monthYear([...active, ...past].map(sponsor => sponsor.createdAt).sort()[0]!)}`, + label: `Sponsors since ${monthYear([...active, ...past].map(sponsor => sponsor.createdAt).toSorted()[0]!)}`, // The past list only renders when there is someone in it. href: past.length > 0 ? '#past-sponsors' : '#sponsors', }, diff --git a/apps/content/pages/_sponsors/format.ts b/apps/content/pages/_sponsors/format.ts index f71fdb229d..a092bdb673 100644 --- a/apps/content/pages/_sponsors/format.ts +++ b/apps/content/pages/_sponsors/format.ts @@ -1,4 +1,8 @@ -const wholeUsd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 }) +const wholeUsd = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, +}) const centsUsd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }) export function usd(value: number): string { @@ -7,13 +11,21 @@ export function usd(value: number): string { // UTC, or a sponsorship that started near midnight lands in a different month // depending on the build machine's zone. -const monthYearFormat = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) +const monthYearFormat = new Intl.DateTimeFormat('en-US', { + month: 'short', + year: 'numeric', + timeZone: 'UTC', +}) export function monthYear(iso: string): string { return monthYearFormat.format(new Date(iso)) } -const yearsFormat = new Intl.NumberFormat('en-US', { style: 'unit', unit: 'year', unitDisplay: 'long' }) +const yearsFormat = new Intl.NumberFormat('en-US', { + style: 'unit', + unit: 'year', + unitDisplay: 'long', +}) export function years(count: number): string { return yearsFormat.format(count) @@ -23,5 +35,7 @@ export function linkLabel(link: string): string { const url = new URL(link) const host = url.hostname.replace(/^www\./u, '') - return host === 'opencollective.com' ? 'Open Collective' : `${host}${url.pathname.replace(/\/$/u, '')}` + return host === 'opencollective.com' + ? 'Open Collective' + : `${host}${url.pathname.replace(/\/$/u, '')}` } diff --git a/apps/content/pages/blog/index.astro b/apps/content/pages/blog/index.astro index 8d750a033d..6f271003ed 100644 --- a/apps/content/pages/blog/index.astro +++ b/apps/content/pages/blog/index.astro @@ -50,7 +50,7 @@ const posts = (await getCollection('docs')) } }) .filter(post => post.href) - .sort((a, b) => Number(b.date ?? 0) - Number(a.date ?? 0)) + .toSorted((a, b) => Number(b.date ?? 0) - Number(a.date ?? 0)) // Tabs come from the tags the posts actually use, most-used first. const tagCounts = new Map() @@ -60,7 +60,7 @@ for (const post of posts) { } } const tags = [...tagCounts.keys()] - .sort((a, b) => (tagCounts.get(b)! - tagCounts.get(a)!) || a.localeCompare(b)) + .toSorted((a, b) => (tagCounts.get(b)! - tagCounts.get(a)!) || a.localeCompare(b)) const feed = data.feeds.find(f => f.href.startsWith('/blog')) diff --git a/apps/content/search/code-index.ts b/apps/content/search/code-index.ts index 3448d383d3..585448a738 100644 --- a/apps/content/search/code-index.ts +++ b/apps/content/search/code-index.ts @@ -22,9 +22,10 @@ * preview pane and the hosted syncs all read the enriched `content` unchanged. */ -import type { Plugin } from 'vite' import { fileURLToPath } from 'node:url' + import { stringifyJSON } from '@orpc/shared' +import type { Plugin } from 'vite' /** Vite's id for the module this plugin rewrites (a resolved virtual module). */ const SEARCH_INDEX = '\0blume:search-index' @@ -37,7 +38,8 @@ const DEFAULT_EXPORT = 'export default ' * the opening run — the ambiguity that would let a long line of backticks * backtrack quadratically. */ -const FENCE = /^[ \t]*(?`{3,})(?(?:[^\n`][^\n]*)?)\n(?[\s\S]*?)^[ \t]*\k[ \t]*$/gmu +const FENCE = + /^[ \t]*(?`{3,})(?(?:[^\n`][^\n]*)?)\n(?[\s\S]*?)^[ \t]*\k[ \t]*$/gmu /** * Fence bodies Blume renders as something other than code, so their text is @@ -81,9 +83,7 @@ function extractCode(markdown: string): string { continue } - const body = (match.groups?.body ?? '') - .replaceAll(TWOSLASH_LINE, '') - .replaceAll(NOTATION, '') + const body = (match.groups?.body ?? '').replaceAll(TWOSLASH_LINE, '').replaceAll(NOTATION, '') for (const line of body.split('\n')) { const trimmed = line.trim() @@ -101,7 +101,10 @@ function extractCode(markdown: string): string { * and the preview pane can find the block a query matched. Imported by the * rewritten index module, so it runs wherever that module evaluates. */ -export function foldCodeIntoSearchIndex(documents: IndexedDocument[], raw: RawMarkdown): IndexedDocument[] { +export function foldCodeIntoSearchIndex( + documents: IndexedDocument[], + raw: RawMarkdown, +): IndexedDocument[] { // Rewritten in place: the snapshot module parses a fresh copy on evaluation, // and the endpoint is its only importer. for (const doc of documents) { @@ -129,7 +132,9 @@ export function searchCodeIndexPlugin(): Plugin { // Fail the build loudly rather than ship an index without code: a newer // Blume that loads the snapshot differently needs this hook updated. if (!code.startsWith(DEFAULT_EXPORT)) { - this.error(`Unexpected shape for ${id.slice(1)}; update apps/content/search/code-index.ts for this Blume version.`) + this.error( + `Unexpected shape for ${id.slice(1)}; update apps/content/search/code-index.ts for this Blume version.`, + ) } const snapshot = code.slice(DEFAULT_EXPORT.length).trim().replace(/;$/u, '') diff --git a/apps/content/search/page-preview.ts b/apps/content/search/page-preview.ts index 215eb8ff61..9ab785652b 100644 --- a/apps/content/search/page-preview.ts +++ b/apps/content/search/page-preview.ts @@ -117,8 +117,7 @@ function evict(cache: Map, limit: number): void { function whenIdle(work: () => void): void { if (typeof requestIdleCallback === 'function') { requestIdleCallback(work) - } - else { + } else { setTimeout(work) } } @@ -139,7 +138,7 @@ function loadPage(url: string): Page { const page: Page = { article: fetch(url, { headers: { Accept: 'text/html' } }) - .then(response => (response.ok ? response.text() : '')) + .then((response) => (response.ok ? response.text() : '')) .then((html) => { const found = html ? new DOMParser().parseFromString(html, 'text/html').querySelector('article') @@ -227,8 +226,7 @@ function markMatches(root: Element, words: string[]): boolean { mark.textContent = part replacement.append(mark) marked = true - } - else { + } else { replacement.append(part) } } @@ -251,7 +249,7 @@ function markQuery(root: Element, words: string[]): void { return } const parts = [ - ...new Set(words.flatMap(word => word.split(NON_WORD)).filter(part => part.length > 1)), + ...new Set(words.flatMap((word) => word.split(NON_WORD)).filter((part) => part.length > 1)), ] if (parts.length > 0) { markMatches(root, parts) @@ -296,10 +294,10 @@ function isolate(root: Element): void { function buildSection(article: Element, words: string[]): HTMLElement { const section = document.createElement('div') section.setAttribute(OWN, '') - section.className - = 'prose max-w-none [&_mark]:rounded-sm [&_mark]:bg-accent/25 [&_mark]:text-inherit' + section.className = + 'prose max-w-none [&_mark]:rounded-sm [&_mark]:bg-accent/25 [&_mark]:text-inherit' section.style.zoom = ZOOM - section.append(...[...article.children].map(block => block.cloneNode(true))) + section.append(...[...article.children].map((block) => block.cloneNode(true))) isolate(section) markQuery(section, words) @@ -355,7 +353,7 @@ function revealTab(mark: Element): void { return } const panels = [...content.children] - const index = panels.findIndex(panel => panel.contains(mark)) + const index = panels.findIndex((panel) => panel.contains(mark)) if (index < 0) { return } @@ -417,9 +415,7 @@ function locate(preview: HTMLElement, section: HTMLElement): number | null { const top = section.getBoundingClientRect().top const heading = blocks[start]!.getBoundingClientRect().top - top - SCROLL_PADDING const match = mark ? mark.getBoundingClientRect().top - top : heading - return match - heading > preview.clientHeight - ? match - preview.clientHeight / 2 - : heading + return match - heading > preview.clientHeight ? match - preview.clientHeight / 2 : heading } /** @@ -453,7 +449,7 @@ function measure(preview: HTMLElement, built: Built): void { // invalidates the layout that the next read then forces again — a page's worth // of layouts instead of one, which costs more than this saves. const blocks = [...built.section.children] as HTMLElement[] - const heights = blocks.map(block => block.offsetHeight) + const heights = blocks.map((block) => block.offsetHeight) for (const [index, block] of blocks.entries()) { const height = heights[index]! if (height > 0) { @@ -473,18 +469,14 @@ function openAt(preview: HTMLElement, built: Built): void { if (built.offset === null) { return } - const top - = built.section.getBoundingClientRect().top - - preview.getBoundingClientRect().top - + preview.scrollTop + const top = + built.section.getBoundingClientRect().top - + preview.getBoundingClientRect().top + + preview.scrollTop preview.scrollTop = top + built.offset } -function start( - preview: HTMLElement, - results: HTMLElement, - input: HTMLInputElement, -): void { +function start(preview: HTMLElement, results: HTMLElement, input: HTMLInputElement): void { // Every selection change supersedes the pending one: the fetch it awaits can // land after the reader has arrowed on, and appending then would attach a // stale page to a different result's preview. @@ -576,10 +568,8 @@ function start( const observer = new MutationObserver((records) => { // Ignore our own append, which is itself a childList mutation here. - const ours = records.every(record => - [...record.addedNodes].every( - node => node instanceof Element && node.hasAttribute(OWN), - ), + const ours = records.every((record) => + [...record.addedNodes].every((node) => node instanceof Element && node.hasAttribute(OWN)), ) if (ours) { return @@ -591,8 +581,7 @@ function start( const selected = selectedRow() if (selected && pages.get(selected.href)?.settled) { enrich() - } - else { + } else { timer = window.setTimeout(enrich, DEBOUNCE) } }) diff --git a/apps/content/shared/contract-first.ts b/apps/content/shared/contract-first.ts index f4332373f3..5ba3379057 100644 --- a/apps/content/shared/contract-first.ts +++ b/apps/content/shared/contract-first.ts @@ -1,7 +1,7 @@ -import * as z from 'zod' import type { RouterContractClient } from '@orpc/contract' import { oc } from '@orpc/contract' import { implement } from '@orpc/server' +import * as z from 'zod' const PlanetSchema = z.object({ id: z.number(), @@ -9,12 +9,9 @@ const PlanetSchema = z.object({ description: z.string().optional(), }) -export const listPlanetsContract = oc - .output(z.array(PlanetSchema)) +export const listPlanetsContract = oc.output(z.array(PlanetSchema)) -export const findPlanetContract = oc - .input(z.object({ id: z.number() })) - .output(PlanetSchema) +export const findPlanetContract = oc.input(z.object({ id: z.number() })).output(PlanetSchema) export const createPlanetContract = oc .input(z.object({ name: z.string(), description: z.string().optional() })) @@ -30,23 +27,20 @@ export const contract = { const os = implement(contract) -export const listPlanets = os.planet.list - .handler(async () => { - return [ - { id: 1, name: 'Earth' }, - { id: 2, name: 'Mars' }, - ] - }) - -export const findPlanet = os.planet.find - .handler(async ({ input }) => { - return { id: input.id, name: 'Earth' } - }) - -export const createPlanet = os.planet.create - .handler(async ({ input }) => { - return { id: 3, ...input } - }) +export const listPlanets = os.planet.list.handler(async () => { + return [ + { id: 1, name: 'Earth' }, + { id: 2, name: 'Mars' }, + ] +}) + +export const findPlanet = os.planet.find.handler(async ({ input }) => { + return { id: input.id, name: 'Earth' } +}) + +export const createPlanet = os.planet.create.handler(async ({ input }) => { + return { id: 3, ...input } +}) export const router = os.router({ planet: { diff --git a/apps/content/shared/getting-started.ts b/apps/content/shared/getting-started.ts index 882f5e45f7..f3d7fcb141 100644 --- a/apps/content/shared/getting-started.ts +++ b/apps/content/shared/getting-started.ts @@ -1,20 +1,17 @@ -import * as z from 'zod' import type { RouterClient } from '@orpc/server' import { os } from '@orpc/server' +import * as z from 'zod' -export const listPlanets = os - .handler(async () => { - return [ - { id: 1, name: 'Earth' }, - { id: 2, name: 'Mars' }, - ] - }) +export const listPlanets = os.handler(async () => { + return [ + { id: 1, name: 'Earth' }, + { id: 2, name: 'Mars' }, + ] +}) -export const findPlanet = os - .input(z.object({ id: z.number() })) - .handler(async ({ input }) => { - return { id: input.id, name: 'Earth' } - }) +export const findPlanet = os.input(z.object({ id: z.number() })).handler(async ({ input }) => { + return { id: input.id, name: 'Earth' } +}) export const createPlanet = os .input(z.object({ name: z.string(), description: z.string().optional() })) diff --git a/apps/content/shared/planet.ts b/apps/content/shared/planet.ts index ded398fdb5..bd571ae44d 100644 --- a/apps/content/shared/planet.ts +++ b/apps/content/shared/planet.ts @@ -1,8 +1,9 @@ -import * as z from 'zod' +import type { IncomingHttpHeaders } from 'node:http' + import { oc } from '@orpc/contract' import type { RouterClient } from '@orpc/server' import { implement } from '@orpc/server' -import type { IncomingHttpHeaders } from 'node:http' +import * as z from 'zod' export const PlanetSchema = z.object({ id: z.number().int().min(1), @@ -19,13 +20,9 @@ export const listPlanetContract = oc ) .output(z.array(PlanetSchema)) -export const findPlanetContract = oc - .input(PlanetSchema.pick({ id: true })) - .output(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 createPlanetContract = oc.input(PlanetSchema.omit({ id: true })).output(PlanetSchema) export const contract = { planet: { list: listPlanetContract, find: findPlanetContract, create: createPlanetContract }, @@ -33,21 +30,20 @@ export const contract = { const os = implement(contract).$context<{ headers?: IncomingHttpHeaders }>() -export const listPlanet = os.planet.list - .handler(({ input }) => { - return [] - }) +export const listPlanet = os.planet.list.handler(({ input }) => { + return [] +}) -export const findPlanet = os.planet.find - .handler(({ input }) => { - return { id: 123, name: 'name' } - }) +export const findPlanet = os.planet.find.handler(({ input }) => { + return { id: 123, name: 'name' } +}) -export const createPlanet = os.planet.create - .handler(({ input }) => { - return { id: 123, name: 'name' } - }) +export const createPlanet = os.planet.create.handler(({ input }) => { + return { id: 123, name: 'name' } +}) -export const router = os.router({ planet: { list: listPlanet, find: findPlanet, create: createPlanet } }) +export const router = os.router({ + planet: { list: listPlanet, find: findPlanet, create: createPlanet }, +}) export const client = {} as RouterClient diff --git a/apps/content/sponsors/ads.ts b/apps/content/sponsors/ads.ts index 8ab2c5696e..12491857f5 100644 --- a/apps/content/sponsors/ads.ts +++ b/apps/content/sponsors/ads.ts @@ -10,7 +10,7 @@ * the sponsor wall's data has no business in the browser bundle — slots.ts * carries only the handful of sponsors that bought a position. */ -import slots from './slots' +import slots from './slots.ts' export interface AdSponsor { name: string @@ -30,7 +30,7 @@ export interface AdSponsor { * light mode and 10% in dark — so the card reads as a surface rather than a * banner and --blume-muted-foreground still clears AA on top. Omit for none. */ - background?: { light: string, dark: string } + background?: { light: string; dark: string } } /** @@ -42,7 +42,7 @@ const DEFAULT_SLOT_COUNT = 6 const soldPositions = slots .map(({ position }) => position) - .filter(position => Number.isInteger(position) && position >= 1) + .filter((position) => Number.isInteger(position) && position >= 1) const slotCount = Math.max(DEFAULT_SLOT_COUNT, ...soldPositions) @@ -76,12 +76,14 @@ export interface AdSlot { /** Every position in order, unsold ones included — what the grid renders. */ export function adSlots(): AdSlot[] { - return AD_POSITIONS.map(position => ({ position, sponsor: inventory[position] ?? null })) + return AD_POSITIONS.map((position) => ({ position, sponsor: inventory[position] ?? null })) } /** Sold sponsors in position order — the pool an inline slot draws from. */ export function soldSponsors(): AdSponsor[] { - return AD_POSITIONS.map(position => inventory[position]).filter(sponsor => sponsor !== undefined) + return AD_POSITIONS.map((position) => inventory[position]).filter( + (sponsor) => sponsor !== undefined, + ) } /** diff --git a/apps/content/sponsors/avatar-fallback.ts b/apps/content/sponsors/avatar-fallback.ts index 94ea94abce..d68aa94823 100644 --- a/apps/content/sponsors/avatar-fallback.ts +++ b/apps/content/sponsors/avatar-fallback.ts @@ -27,10 +27,14 @@ function sweep(): void { // `error` does not bubble, so it is caught in the capture phase; the sweep // covers images that failed before this deferred module ran. -document.addEventListener('error', (event) => { - if (event.target instanceof HTMLImageElement) { - useMonogram(event.target) - } -}, true) +document.addEventListener( + 'error', + (event) => { + if (event.target instanceof HTMLImageElement) { + useMonogram(event.target) + } + }, + true, +) sweep() diff --git a/apps/content/sponsors/client.ts b/apps/content/sponsors/client.ts index 13135564ad..936e416487 100644 --- a/apps/content/sponsors/client.ts +++ b/apps/content/sponsors/client.ts @@ -1,5 +1,5 @@ -import type { AdSponsor } from './ads' -import { relAttribute, soldSponsors, tintStyle } from './ads' +import type { AdSponsor } from './ads.ts' +import { relAttribute, soldSponsors, tintStyle } from './ads.ts' // Only the inline slots rotate. The grid is static and carries no // [data-sponsor-slot], so nothing here touches it. @@ -33,8 +33,7 @@ function apply(card: HTMLAnchorElement, sponsor: AdSponsor): void { const tint = tintStyle(sponsor) if (tint === undefined) { card.removeAttribute('style') - } - else { + } else { card.setAttribute('style', tint) } } @@ -76,7 +75,6 @@ function rotate(): void { // parsed; the guard only covers a non-deferred injection. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', rotate) -} -else { +} else { rotate() } diff --git a/apps/content/sponsors/data.ts b/apps/content/sponsors/data.ts index f95a424616..fb543ddcea 100644 --- a/apps/content/sponsors/data.ts +++ b/apps/content/sponsors/data.ts @@ -1,6 +1,6 @@ // The GitHub Sponsors wall behind the landing page and the /sponsors table. // Paid ad slots are a separate concern and live in ads.ts. -import sponsors from './sponsors' +import sponsors from './sponsors.ts' export const GITHUB_SPONSORS_URL = 'https://github.com/sponsors/dinwwwh' export const OPEN_COLLECTIVE_URL = 'https://opencollective.com/middleapi' @@ -10,8 +10,7 @@ export function avatarSrc(src: string, px: number): string { if (url.hostname === 'avatars.githubusercontent.com') { url.searchParams.set('s', String(px * 2)) - } - else if (url.hostname === 'github.com') { + } else if (url.hostname === 'github.com') { url.searchParams.set('size', String(px * 2)) } @@ -73,7 +72,12 @@ export function sponsorTiers(): SponsorTier[] { continue } - tiers.push({ title: sponsor.tierTitle, level: sponsor.tierLevel, total: sponsor.amount, sponsors: [sponsor] }) + tiers.push({ + title: sponsor.tierTitle, + level: sponsor.tierLevel, + total: sponsor.amount, + sponsors: [sponsor], + }) } return tiers @@ -81,5 +85,5 @@ export function sponsorTiers(): SponsorTier[] { /** Sponsors whose sponsorship has ended, still worth thanking. */ export function pastSponsors(): Sponsor[] { - return allSponsors.filter(sponsor => !isActive(sponsor)) + return allSponsors.filter((sponsor) => !isActive(sponsor)) } diff --git a/apps/content/sponsors/inject.ts b/apps/content/sponsors/inject.ts index 4ecb86f87e..e43f8a8fde 100644 --- a/apps/content/sponsors/inject.ts +++ b/apps/content/sponsors/inject.ts @@ -41,9 +41,12 @@ export function injectSlots(source: string): string { } const countWords = (text: string) => text.match(/\S+/g)?.length ?? 0 - const gap = Math.max(WORDS_PER_AD, countWords(lines.slice(bodyStart).join('\n')) / MAX_ADS_PER_PAGE) + const gap = Math.max( + WORDS_PER_AD, + countWords(lines.slice(bodyStart).join('\n')) / MAX_ADS_PER_PAGE, + ) const gridGap = Math.max(gap, WORDS_PER_AD + EXTRA_WORDS_AFTER_GRID_AD) - const gapAfter = (placed: number) => placed === 0 ? 0 : placed === 1 ? gridGap : gap + const gapAfter = (placed: number) => (placed === 0 ? 0 : placed === 1 ? gridGap : gap) let inFence = false let words = 0 @@ -52,8 +55,7 @@ export function injectSlots(source: string): string { const trimmed = lines[i]!.trim() if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) { inFence = !inFence - } - else if (!inFence && /^##\s/.test(trimmed) && words >= gapAfter(insertBefore.length)) { + } else if (!inFence && /^##\s/.test(trimmed) && words >= gapAfter(insertBefore.length)) { insertBefore.push(i) words = 0 } @@ -72,7 +74,7 @@ export function injectSlots(source: string): string { } const contentDirs = ['docs', 'blog'].map( - dir => join(dirname(fileURLToPath(import.meta.url)), '..', dir) + sep, + (dir) => join(dirname(fileURLToPath(import.meta.url)), '..', dir) + sep, ) /** @@ -85,7 +87,7 @@ export function sponsorAdsInjectPlugin() { enforce: 'pre' as const, async load(id: string) { const path = id.split('?')[0]! - if (!path.endsWith('.mdx') || !contentDirs.some(dir => path.startsWith(dir))) { + if (!path.endsWith('.mdx') || !contentDirs.some((dir) => path.startsWith(dir))) { return null } return injectSlots(await readFile(path, 'utf-8')) diff --git a/apps/content/sponsors/slots.ts b/apps/content/sponsors/slots.ts index 690a674258..423a7bb29d 100644 --- a/apps/content/sponsors/slots.ts +++ b/apps/content/sponsors/slots.ts @@ -1,42 +1,40 @@ -/* eslint-disable eslint-comments/no-unlimited-disable */ -/* eslint-disable */ // Generated by scripts/sync-sponsors.ts from https://raw.githubusercontent.com/middleapi/static/refs/heads/main/sponsors.json — do not edit. export default [ { - "position": 1, - "name": "ScreenshotOne.com", - "description": "The screenshot API for developers", - "logo": "https://avatars.githubusercontent.com/u/97035603?v=4", - "href": "https://screenshotone.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "background": { - "light": "#f7f5ff", - "dark": "#303147" - } + position: 1, + name: 'ScreenshotOne.com', + description: 'The screenshot API for developers', + logo: 'https://avatars.githubusercontent.com/u/97035603?v=4', + href: 'https://screenshotone.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + background: { + light: '#f7f5ff', + dark: '#303147', + }, }, { - "position": 2, - "name": "Yuzu", - "description": "We're hiring NYC based engineers", - "logo": "https://avatars.githubusercontent.com/u/102488956?v=4", - "href": "https://yuzu.health/careers?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "background": { - "light": "#fdf5f3", - "dark": "#3d302e" - } + position: 2, + name: 'Yuzu', + description: "We're hiring NYC based engineers", + logo: 'https://avatars.githubusercontent.com/u/102488956?v=4', + href: 'https://yuzu.health/careers?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + background: { + light: '#fdf5f3', + dark: '#3d302e', + }, }, { - "position": 4, - "name": "MisskeyHQ", - "description": "Decentralized microblogging SNS born on Earth", - "logo": "https://github.com/MisskeyIO.png", - "href": "https://misskey.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "background": { - "light": "#f8faf0", - "dark": "#313a2e" - } - } + position: 4, + name: 'MisskeyHQ', + description: 'Decentralized microblogging SNS born on Earth', + logo: 'https://github.com/MisskeyIO.png', + href: 'https://misskey.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + background: { + light: '#f8faf0', + dark: '#313a2e', + }, + }, ] diff --git a/apps/content/sponsors/sponsors.ts b/apps/content/sponsors/sponsors.ts index 5b68107ae5..4d0f866204 100644 --- a/apps/content/sponsors/sponsors.ts +++ b/apps/content/sponsors/sponsors.ts @@ -1,978 +1,1038 @@ -/* eslint-disable eslint-comments/no-unlimited-disable */ -/* eslint-disable */ // Generated by scripts/sync-sponsors.ts from https://raw.githubusercontent.com/middleapi/static/refs/heads/main/sponsors.json — do not edit. export default [ { - "name": "ScreenshotOne.com", - "login": "screenshotone", - "avatar": "https://avatars.githubusercontent.com/u/97035603?v=4", - "link": "https://screenshotone.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Premium Sponsor", - "tierLevel": 4, - "amount": 500, - "createdAt": "2026-03-15T14:00:10Z", - "type": "Organization" - }, - { - "name": "Yuzu", - "login": "yuzu-health", - "avatar": "https://avatars.githubusercontent.com/u/102488956?v=4", - "link": "https://yuzu.health/careers?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Premium Sponsor", - "tierLevel": 4, - "amount": 500, - "createdAt": "2026-08-31T14:58:49Z", - "type": "Organization" - }, - { - "name": "MisskeyHQ", - "login": "SanMurakami", - "avatar": "https://github.com/MisskeyIO.png", - "link": "https://misskey.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Premium Sponsor", - "tierLevel": 4, - "amount": 200, - "createdAt": "2025-03-24T15:22:59Z", - "type": "User" - }, - { - "name": "LN Markets", - "login": "ln-markets", - "avatar": "https://avatars.githubusercontent.com/u/70597625?v=4", - "link": "https://lnmarkets.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Organization Sponsor", - "tierLevel": 3, - "amount": 50, - "createdAt": "2025-10-24T13:08:54Z", - "type": "Organization" - }, - { - "name": "Reece McDonald", - "login": "hrmcdonald", - "avatar": "https://avatars.githubusercontent.com/u/39349270?v=4", - "link": "https://github.com/hrmcdonald?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 25, - "createdAt": "2025-05-12T16:00:19Z", - "type": "User" - }, - { - "name": "あわわわとーにゅ", - "login": "u1-liquid", - "avatar": "https://avatars.githubusercontent.com/u/17376330?u=de3353804be889f009f7e0a1582daf04d0ab292d&v=4", - "link": "https://soymilk.party/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-03-23T23:18:55Z", - "type": "User" - }, - { - "name": "nk", - "login": "nicognaW", - "avatar": "https://avatars.githubusercontent.com/u/66731869?u=4699bda3a9092d3ec34fbd959450767bcc8b8b6d&v=4", - "link": "https://github.com/nicognaW?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-04-16T10:25:27Z", - "type": "User" - }, - { - "name": "supastarter", - "login": "supastarter", - "avatar": "https://avatars.githubusercontent.com/u/110960143?v=4", - "link": "https://supastarter.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-04-16T15:07:31Z", - "type": "Organization" - }, - { - "name": "herrfugbaum", - "login": "herrfugbaum", - "avatar": "https://avatars.githubusercontent.com/u/12859776?u=644dc1666d0220bc0468eb0de3c56b919f635b16&v=4", - "link": "https://github.com/herrfugbaum?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-05-05T11:22:30Z", - "type": "User" - }, - { - "name": "Ryota Murakami", - "login": "ryota-murakami", - "avatar": "https://avatars.githubusercontent.com/u/5501268?u=599389e03340734325726ca3f8f423c021d47d7f&v=4", - "link": "https://laststance.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-05-13T10:18:05Z", - "type": "User" - }, - { - "name": "David Cramer", - "login": "dcramer", - "avatar": "https://avatars.githubusercontent.com/u/23610?v=4", - "link": "https://cra.mr/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-05-16T04:26:52Z", - "type": "User" - }, - { - "name": "Valerii Petryniak", - "login": "valerii15298", - "avatar": "https://avatars.githubusercontent.com/u/44531564?u=88ac74d9bacd20401518441907acad21063cd397&v=4", - "link": "https://valerii15298.github.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-05-22T21:44:12Z", - "type": "User" - }, - { - "name": "Valerii Strilets", - "login": "letstri", - "avatar": "https://avatars.githubusercontent.com/u/13253748?u=c7b10399ccc8f8081e24db94ec32cd9858e86ac3&v=4", - "link": "https://letstri.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-10-10T12:34:54Z", - "type": "User" - }, - { - "name": "Kyle Mistele", - "login": "K-Mistele", - "avatar": "https://avatars.githubusercontent.com/u/18430555?u=3afebeb81de666e35aaac3ed46f14159d7603ffb&v=4", - "link": "https://blacklight.sh/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-10-19T00:51:05Z", - "type": "User" - }, - { - "name": "christ12938", - "login": "christ12938", - "avatar": "https://avatars.githubusercontent.com/u/25758598?v=4", - "link": "https://github.com/christ12938?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2025-10-30T02:44:00Z", - "type": "User" - }, - { - "name": "Ryan Soderberg", - "login": "Ryanjso", - "avatar": "https://avatars.githubusercontent.com/u/39172778?u=5ed913c31d57e7221b75784abcad48c7ebddde27&v=4", - "link": "https://github.com/Ryanjso?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2026-01-30T23:33:16Z", - "type": "User" - }, - { - "name": "shota", - "login": "itigoore01", - "avatar": "https://avatars.githubusercontent.com/u/11831107?u=c976a6dc7e055eb026304c46c99100ed22b0c8e0&v=4", - "link": "https://github.com/itigoore01?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2026-05-21T00:28:12Z", - "type": "User" - }, - { - "name": "Nicholas", - "login": "Nic13Gamer", - "avatar": "https://avatars.githubusercontent.com/u/54724556?u=56a7ab430ce7a80d648ab6eba051d454a818ed0b&v=4", - "link": "https://github.com/Nic13Gamer?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2026-06-28T20:17:54Z", - "type": "User" - }, - { - "name": "Ellis Driscoll", - "login": "ellis-driscoll", - "avatar": "https://avatars.githubusercontent.com/u/70685966?u=c5f95bc33b5991d9744abe00052542e4a2ed3cb9&v=4", - "link": "https://github.com/ellis-driscoll?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2026-07-30T02:53:37Z", - "type": "User" - }, - { - "name": "Hoang Nguyen", - "login": "hoangbn", - "avatar": "https://avatars.githubusercontent.com/u/38968280?u=c90084c6de65c56facabab7ba13a72a49ddbc3e4&v=4", - "link": "https://github.com/hoangbn?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2026-08-13T15:18:49Z", - "type": "User" - }, - { - "name": "Orestis Ioannou", - "login": "guest-ac41de3b", - "avatar": "https://images.opencollective.com/guest-ac41de3b/avatar/460.png", - "link": "https://opencollective.com/guest-ac41de3b?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2026-08-25T08:43:42.803Z", - "type": "User" - }, - { - "name": "Stefan Smiljkovic", - "login": "shtefcs", - "avatar": "https://avatars.githubusercontent.com/u/1984909?u=b7bf5bc40ed49df3c22f69d2da7b8d78709c49ed&v=4", - "link": "https://automatio.ai/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Sponsor", - "tierLevel": 2, - "amount": 10, - "createdAt": "2026-09-08T14:51:48Z", - "type": "User" - }, - { - "name": "David Walsh", - "login": "rhinodavid", - "avatar": "https://avatars.githubusercontent.com/u/5778036?u=b5521f07d2f88c3db2a0dae62b5f2f8357214af0&v=4", - "link": "https://github.com/rhinodavid?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 8, - "createdAt": "2025-08-07T03:26:10Z", - "type": "User" - }, - { - "name": "IPv4Addr", - "login": "IPv4Addr", - "avatar": "https://avatars.githubusercontent.com/u/100147665?u=59996b72f69bb53063cb7e9ff8b8f898616cd94d&v=4", - "link": "https://github.com/IPv4Addr?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2025-03-12T10:09:06Z", - "type": "User" - }, - { - "name": "Robbe Vaes", - "login": "Robbe95", - "avatar": "https://avatars.githubusercontent.com/u/44748019?u=e0232402c045ad4eac7cbd217f1f47e083103b89&v=4", - "link": "https://robbevaes.be/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2025-04-12T08:00:55Z", - "type": "User" - }, - { - "name": "Aidan Sunbury", - "login": "aidansunbury", - "avatar": "https://avatars.githubusercontent.com/u/64103161?v=4", - "link": "https://github.com/aidansunbury?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2025-05-20T05:38:01Z", - "type": "User" - }, - { - "name": "soonoo", - "login": "soonoo", - "avatar": "https://avatars.githubusercontent.com/u/5436405?u=5d0b4aa955c87e30e6bda7f0cccae5402da99528&v=4", - "link": "https://github.com/soonoo?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2025-06-01T14:24:29Z", - "type": "User" - }, - { - "name": "Kevin Porten", - "login": "kporten", - "avatar": "https://avatars.githubusercontent.com/u/1839345?u=dc2263d5cfe0d927ce1a0be04a1d55dd6b55405c&v=4", - "link": "https://kevinporten.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2025-10-03T12:33:00Z", - "type": "User" - }, - { - "name": "Denis", - "login": "pumpkinlink", - "avatar": "https://avatars.githubusercontent.com/u/11864620?u=5f47bbe6c65d0f6f5cf011021490238e4b0593d0&v=4", - "link": "https://github.com/pumpkinlink?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2025-10-07T14:47:51Z", - "type": "User" - }, - { - "name": "Christopher Kapic", - "login": "christopher-kapic", - "avatar": "https://avatars.githubusercontent.com/u/59740769?u=e7ad4b72b5bf6c9eb1644c26dbf3332a8f987377&v=4", - "link": "https://github.com/christopher-kapic?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2025-10-24T04:00:19Z", - "type": "User" - }, - { - "name": "Tom Ballinger", - "login": "thomasballinger", - "avatar": "https://avatars.githubusercontent.com/u/458879?u=4b045ac75d721b6ac2b42a74d7d37f61f0414031&v=4", - "link": "http://ballingt.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2025-10-24T18:29:25Z", - "type": "User" - }, - { - "name": "Sam", - "login": "SSam0419", - "avatar": "https://avatars.githubusercontent.com/u/102863520?u=3c89611f549d5070be232eb4532f690c8f2e7a65&v=4", - "link": "https://lee-sam.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2025-11-18T05:36:22Z", - "type": "User" - }, - { - "name": "Titoine", - "login": "Titoine", - "avatar": "https://avatars.githubusercontent.com/u/3514286?u=1bb1e86b0c99c8a1121372e56d51a177eea12191&v=4", - "link": "https://github.com/Titoine?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2026-01-09T14:08:34Z", - "type": "User" - }, - { - "name": "Igor Makowski", - "login": "Mnigos", - "avatar": "https://avatars.githubusercontent.com/u/56691628?u=ee8c879478f7c151b9156aef6c74243fa3e247a8&v=4", - "link": "https://rigtch.fm/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2026-01-19T21:33:37Z", - "type": "User" - }, - { - "name": "hanayashiki", - "login": "hanayashiki", - "avatar": "https://avatars.githubusercontent.com/u/26056783?u=06c3b9205a16fd41a871e82da1cc2a09306d53f5&v=4", - "link": "https://blog.cwang.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2026-04-04T08:42:19Z", - "type": "User" - }, - { - "name": "Lev Dubinets", - "login": "ldub", - "avatar": "https://avatars.githubusercontent.com/u/3114081?u=f547f5d5012cab54851f1b1ad72d10e537f78fc2&v=4", - "link": "https://dubinets.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2026-06-08T03:38:25Z", - "type": "User" - }, - { - "name": "Kelly Peilin Chan", - "login": "mr-kelly", - "avatar": "https://avatars.githubusercontent.com/u/520852?u=6b0f7105f694e7b5cacf410a3f04c7044b469dc8&v=4", - "link": "https://kellychan.im/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2026-06-13T15:03:13Z", - "type": "User" - }, - { - "name": "Guy Ariely", - "login": "guyariely", - "avatar": "https://avatars.githubusercontent.com/u/42813496?u=edb6b7f563bf28e160a290832e7da57c0506f8ca&v=4", - "link": "https://guyariely.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2026-08-01T14:25:58Z", - "type": "User" - }, - { - "name": "PaulSenon", - "login": "PaulSenon", - "avatar": "https://avatars.githubusercontent.com/u/19531087?u=6385741eb91d200b8c513ec6045482e301767ca6&v=4", - "link": "https://paulsenon.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 5, - "createdAt": "2026-08-30T03:12:32Z", - "type": "User" - }, - { - "name": "Alex", - "login": "piscis", - "avatar": "https://avatars.githubusercontent.com/u/326163?u=b245f368bd940cf51d08c0b6bf55f8257f359437&v=4", - "link": "https://piscis.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 1, - "createdAt": "2025-04-30T09:57:34Z", - "type": "User" - }, - { - "name": "Andrey Gubanov", - "login": "finom", - "avatar": "https://avatars.githubusercontent.com/u/1082083?u=c5f2daf7ebece498e85c83367bb37b4e10e2649d&v=4", - "link": "https://opensource.gubanov.eu/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Backer", - "tierLevel": 1, - "amount": 1, - "createdAt": "2026-05-29T20:29:45Z", - "type": "User" - }, - { - "name": "Maxie", - "login": "MrMaxie", - "avatar": "https://avatars.githubusercontent.com/u/3857836?u=5e6b57973d4385d655663ffdd836e487856f2984&v=4", - "link": "https://maxie.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-03-11T02:22:27Z", - "type": "User" - }, - { - "name": "Zuplo", - "login": "zuplo", - "avatar": "https://avatars.githubusercontent.com/u/85497839?v=4", - "link": "https://zuplo.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-04-02T05:15:32Z", - "type": "Organization" - }, - { - "name": "motopods", - "login": "motopods", - "avatar": "https://avatars.githubusercontent.com/u/58200641?v=4", - "link": "https://github.com/motopods?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-04-09T14:23:49Z", - "type": "User" - }, - { - "name": "Francisco Hermida", - "login": "franciscohermida", - "avatar": "https://avatars.githubusercontent.com/u/483242?u=bbcbc80eb9d8781ff401f7dafc3b59cd7bea0561&v=4", - "link": "https://github.com/franciscohermida?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-05-02T01:10:38Z", - "type": "User" - }, - { - "name": "Dexter Miguel", - "login": "divmgl", - "avatar": "https://avatars.githubusercontent.com/u/5452298?u=645993204be8696c085ecf0d228c3062efe2ed65&v=4", - "link": "https://github.com/divmgl?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-05-02T03:29:29Z", - "type": "User" - }, - { - "name": "Théo LUDWIG", - "login": "theoludwig", - "avatar": "https://avatars.githubusercontent.com/u/25207499?u=a6a9653725a2f574c07893748806668e0598cdbe&v=4", - "link": "https://theoludwig.fr/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-05-09T13:11:36Z", - "type": "User" - }, - { - "name": "Abhay Ramesh", - "login": "abhay-ramesh", - "avatar": "https://avatars.githubusercontent.com/u/66196314?u=c5c2b0327b26606c2efcfaf17046ab18c3d25c57&v=4", - "link": "https://abhayramesh.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-05-10T17:41:05Z", - "type": "User" - }, - { - "name": "shr-ink", - "login": "shr-ink", - "avatar": "https://avatars.githubusercontent.com/u/139700438?v=4", - "link": "https://github.com/shr-ink?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-05-11T15:27:14Z", - "type": "Organization" - }, - { - "name": "0x4e32", - "login": "johngerome", - "avatar": "https://avatars.githubusercontent.com/u/2002000?u=505e54608466ab53754f702973687b04c6424c1f&v=4", - "link": "https://0x4e32.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-05-13T05:53:13Z", - "type": "User" - }, - { - "name": "ryuz", - "login": "ryuzdev", - "avatar": "https://avatars.githubusercontent.com/u/196539378?u=d38374588d219b6748b16406982f6559411466d4&v=4", - "link": "https://github.com/ryuzdev?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-06-15T15:08:09Z", - "type": "User" - }, - { - "name": "happyboy", - "login": "happyboy2022", - "avatar": "https://avatars.githubusercontent.com/u/103669586?u=65b49c4b893ed3703909fbb3a7a22313f3f9c121&v=4", - "link": "https://github.com/happyboy2022?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-07-01T16:25:19Z", - "type": "User" - }, - { - "name": "yicchi", - "login": "YiCChi", - "avatar": "https://avatars.githubusercontent.com/u/86967274?u=6c2756f09fe15dd94d572f560e979cd157982852&v=4", - "link": "https://github.com/YiCChi?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-07-17T02:52:53Z", - "type": "User" - }, - { - "name": "Saksham", - "login": "cloudycotton", - "avatar": "https://avatars.githubusercontent.com/u/168998965?u=9b9634a5aed66a51c1b880663272725b00b92b14&v=4", - "link": "https://saksham.work/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-07-22T21:32:51Z", - "type": "User" - }, - { - "name": "Roman Hrynevych", - "login": "hrynevychroman", - "avatar": "https://avatars.githubusercontent.com/u/82209198?u=1a1d111ab3d589855b9cc8a7fefb1b5c6a4fbbaf&v=4", - "link": "https://hrynevych.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-07-23T12:14:14Z", - "type": "User" - }, - { - "name": "rokitg", - "login": "rokitgg", - "avatar": "https://avatars.githubusercontent.com/u/125133357?u=06c74aefaa2236b06a2e5fba5a5c612339f45912&v=4", - "link": "https://github.com/rokitgg?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-08-06T09:38:53Z", - "type": "User" - }, - { - "name": "Omar Khatib", - "login": "omarkhatibgg", - "avatar": "https://avatars.githubusercontent.com/u/9054278?u=afbba7331b85c51b8eee4130f5fd31b1017dc919&v=4", - "link": "http://khatib.gg/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-08-14T10:39:47Z", - "type": "User" - }, - { - "name": "Yu-Sabo", - "login": "YuSabo90002", - "avatar": "https://avatars.githubusercontent.com/u/13120582?u=992c15f5438a3120e01f0497d45efb9e85c4994d&v=4", - "link": "https://github.com/YuSabo90002?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-08-20T12:50:33Z", - "type": "User" - }, - { - "name": "Bapusaheb Patil", - "login": "bapspatil", - "avatar": "https://avatars.githubusercontent.com/u/16699418?u=6d9d8e0a64a6f91ca1c4d559c72d931172bdcbbd&v=4", - "link": "https://bapspatil.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-10-09T05:39:47Z", - "type": "User" - }, - { - "name": "grim", - "login": "ripgrim", - "avatar": "https://avatars.githubusercontent.com/u/75869731?u=cc07a1b835267771bc03954ea0bb1446fe1208b5&v=4", - "link": "https://github.com/ripgrim?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-10-11T03:34:04Z", - "type": "User" - }, - { - "name": "Nelson Lai", - "login": "nelsonlaidev", - "avatar": "https://avatars.githubusercontent.com/u/75498339?u=2fc0e0b95dd184c5ffb744df977cb15a18b60672&v=4", - "link": "https://nelsonlai.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-10-12T11:26:42Z", - "type": "User" - }, - { - "name": "Lê Cao Nguyên", - "login": "nguyenlc1993", - "avatar": "https://avatars.githubusercontent.com/u/13871971?u=83c8b69d9e35b589c4e1f066cc113b1d9461386f&v=4", - "link": "https://github.com/nguyenlc1993?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-10-22T09:23:09Z", - "type": "User" - }, - { - "name": "Robert Soriano", - "login": "wobsoriano", - "avatar": "https://avatars.githubusercontent.com/u/13049130?u=6d72104182e7c9ed25934815313fb69107332111&v=4", - "link": "https://robsoriano.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-10-25T02:59:20Z", - "type": "User" - }, - { - "name": "Andrew Peters", - "login": "andrewpeters9", - "avatar": "https://avatars.githubusercontent.com/u/36251325?v=4", - "link": "https://github.com/andrewpeters9?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-10-26T03:26:25Z", - "type": "User" - }, - { - "name": "Ryan Vogel", - "login": "R44VC0RP", - "avatar": "https://avatars.githubusercontent.com/u/89211796?u=1857347b9787d8d8a7ea5bfc333f96be92d5a683&v=4", - "link": "https://ryan.ceo/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-10-26T15:57:48Z", - "type": "User" - }, - { - "name": "SKostyukovich", - "login": "SKostyukovich", - "avatar": "https://avatars.githubusercontent.com/u/10700067?v=4", - "link": "https://github.com/SKostyukovich?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-10-27T04:21:43Z", - "type": "User" - }, - { - "name": "Peter Adam", - "login": "peter-adam-dy", - "avatar": "https://avatars.githubusercontent.com/u/132129459?u=4f3dbbb3b443990b56acb7d6a5d11ed2c555f6db&v=4", - "link": "https://github.com/peter-adam-dy?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-11-02T13:17:28Z", - "type": "User" - }, - { - "name": "Fabworks", - "login": "FabworksHQ", - "avatar": "https://avatars.githubusercontent.com/u/160179500?v=4", - "link": "https://fabworks.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-11-04T17:26:27Z", - "type": "Organization" - }, - { - "name": "Novak Antonijevic", - "login": "NovakAnton", - "avatar": "https://avatars.githubusercontent.com/u/157126729?u=ae49fa22292d55c0434ff0ca008206155b18663b&v=4", - "link": "https://github.com/NovakAnton?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-11-15T10:28:15Z", - "type": "User" - }, - { - "name": "Laduni Estu Syalwa", - "login": "laduniestu", - "avatar": "https://avatars.githubusercontent.com/u/44757637?u=a2fc1ea8f7d827a96721176f79d30592d1c48059&v=4", - "link": "https://dun.gg/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-11-17T14:33:48Z", - "type": "User" - }, - { - "name": "Chen, Zhi-Yuan", - "login": "yukimotochern", - "avatar": "https://avatars.githubusercontent.com/u/20896173?u=945c33fc21725e4d566a0d02afc54b136ca1d67a&v=4", - "link": "https://github.com/yukimotochern?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2025-12-06T01:58:38Z", - "type": "User" - }, - { - "name": "Illarion Koperski", - "login": "illarionvk", - "avatar": "https://avatars.githubusercontent.com/u/5012724?u=7cfa13652f7ac5fb3c56d880e3eb3fbe40c3ea34&v=4", - "link": "https://illarionvk.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-01-19T11:54:08Z", - "type": "User" - }, - { - "name": "Anees Iqbal", - "login": "steelbrain", - "avatar": "https://avatars.githubusercontent.com/u/4278113?u=22b80b5399eed68ac76cd58b02961b0481f1db11&v=4", - "link": "https://aneesiqbal.ai/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-01-28T05:37:06Z", - "type": "User" - }, - { - "name": "Sefa Eyeoglu", - "login": "Scrumplex", - "avatar": "https://avatars.githubusercontent.com/u/11587657?u=ab503582165c0bbff0cca47ce31c9450bb1553c9&v=4", - "link": "https://scrumplex.net/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-02-19T13:13:45Z", - "type": "User" - }, - { - "name": "natt", - "login": "nattstack", - "avatar": "https://avatars.githubusercontent.com/u/31426677?u=fa9dbb8b3e66eb0ea3c88db5dc07f31c8c5418fe&v=4", - "link": "https://natt.so/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-04-23T23:23:31Z", - "type": "User" - }, - { - "name": "Adam Tkaczyk", - "login": "ChromeGG", - "avatar": "https://avatars.githubusercontent.com/u/39050595?u=a58ca6042a6950e94e6e92442db76ef584279bc0&v=4", - "link": "https://tkaczyk.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-05-15T07:51:35Z", - "type": "User" - }, - { - "name": "plancraft", - "login": "plancraft", - "avatar": "https://avatars.githubusercontent.com/u/46482287?v=4", - "link": "https://plancraft.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-05-18T11:16:16Z", - "type": "Organization" - }, - { - "name": "Guillermo Rauch", - "login": "rauchg", - "avatar": "https://avatars.githubusercontent.com/u/13041?u=1ee8d111657cdd02ff6d253df00978d17ee6d722&v=4", - "link": "http://twitter.com/rauchg?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-09-09T19:11:39Z", - "type": "User" - }, - { - "name": "Nexa", - "login": "nexa-ca", - "avatar": "https://avatars.githubusercontent.com/u/199146462?v=4", - "link": "https://github.com/nexa-ca?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-09-21T17:06:10Z", - "type": "Organization" - }, - { - "name": "Laduni", - "login": "laduni", - "avatar": "https://images.opencollective.com/laduni/avatar/460.png", - "link": "https://opencollective.com/laduni?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-09-28T14:22:45.552Z", - "type": "User" - }, - { - "name": "Shotaro Nakamura", - "login": "nakasyou", - "avatar": "https://avatars.githubusercontent.com/u/79000684?u=f644df3f29f0e8677a90967115774564f1d9d6ab&v=4", - "link": "https://nakasyou.how/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor", - "rel": "sponsored", - "tierTitle": "Past Sponsor", - "tierLevel": 0, - "amount": -1, - "createdAt": "2026-09-28T15:56:34Z", - "type": "User" - } + name: 'ScreenshotOne.com', + login: 'screenshotone', + avatar: 'https://avatars.githubusercontent.com/u/97035603?v=4', + link: 'https://screenshotone.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Premium Sponsor', + tierLevel: 4, + amount: 500, + createdAt: '2026-03-15T14:00:10Z', + type: 'Organization', + }, + { + name: 'Yuzu', + login: 'yuzu-health', + avatar: 'https://avatars.githubusercontent.com/u/102488956?v=4', + link: 'https://yuzu.health/careers?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Premium Sponsor', + tierLevel: 4, + amount: 500, + createdAt: '2026-08-31T14:58:49Z', + type: 'Organization', + }, + { + name: 'MisskeyHQ', + login: 'SanMurakami', + avatar: 'https://github.com/MisskeyIO.png', + link: 'https://misskey.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Premium Sponsor', + tierLevel: 4, + amount: 200, + createdAt: '2025-03-24T15:22:59Z', + type: 'User', + }, + { + name: 'LN Markets', + login: 'ln-markets', + avatar: 'https://avatars.githubusercontent.com/u/70597625?v=4', + link: 'https://lnmarkets.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Organization Sponsor', + tierLevel: 3, + amount: 50, + createdAt: '2025-10-24T13:08:54Z', + type: 'Organization', + }, + { + name: 'Reece McDonald', + login: 'hrmcdonald', + avatar: 'https://avatars.githubusercontent.com/u/39349270?v=4', + link: 'https://github.com/hrmcdonald?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 25, + createdAt: '2025-05-12T16:00:19Z', + type: 'User', + }, + { + name: 'あわわわとーにゅ', + login: 'u1-liquid', + avatar: + 'https://avatars.githubusercontent.com/u/17376330?u=de3353804be889f009f7e0a1582daf04d0ab292d&v=4', + link: 'https://soymilk.party/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-03-23T23:18:55Z', + type: 'User', + }, + { + name: 'nk', + login: 'nicognaW', + avatar: + 'https://avatars.githubusercontent.com/u/66731869?u=4699bda3a9092d3ec34fbd959450767bcc8b8b6d&v=4', + link: 'https://github.com/nicognaW?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-04-16T10:25:27Z', + type: 'User', + }, + { + name: 'supastarter', + login: 'supastarter', + avatar: 'https://avatars.githubusercontent.com/u/110960143?v=4', + link: 'https://supastarter.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-04-16T15:07:31Z', + type: 'Organization', + }, + { + name: 'herrfugbaum', + login: 'herrfugbaum', + avatar: + 'https://avatars.githubusercontent.com/u/12859776?u=644dc1666d0220bc0468eb0de3c56b919f635b16&v=4', + link: 'https://github.com/herrfugbaum?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-05-05T11:22:30Z', + type: 'User', + }, + { + name: 'Ryota Murakami', + login: 'ryota-murakami', + avatar: + 'https://avatars.githubusercontent.com/u/5501268?u=599389e03340734325726ca3f8f423c021d47d7f&v=4', + link: 'https://laststance.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-05-13T10:18:05Z', + type: 'User', + }, + { + name: 'David Cramer', + login: 'dcramer', + avatar: 'https://avatars.githubusercontent.com/u/23610?v=4', + link: 'https://cra.mr/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-05-16T04:26:52Z', + type: 'User', + }, + { + name: 'Valerii Petryniak', + login: 'valerii15298', + avatar: + 'https://avatars.githubusercontent.com/u/44531564?u=88ac74d9bacd20401518441907acad21063cd397&v=4', + link: 'https://valerii15298.github.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-05-22T21:44:12Z', + type: 'User', + }, + { + name: 'Valerii Strilets', + login: 'letstri', + avatar: + 'https://avatars.githubusercontent.com/u/13253748?u=c7b10399ccc8f8081e24db94ec32cd9858e86ac3&v=4', + link: 'https://letstri.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-10-10T12:34:54Z', + type: 'User', + }, + { + name: 'Kyle Mistele', + login: 'K-Mistele', + avatar: + 'https://avatars.githubusercontent.com/u/18430555?u=3afebeb81de666e35aaac3ed46f14159d7603ffb&v=4', + link: 'https://blacklight.sh/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-10-19T00:51:05Z', + type: 'User', + }, + { + name: 'christ12938', + login: 'christ12938', + avatar: 'https://avatars.githubusercontent.com/u/25758598?v=4', + link: 'https://github.com/christ12938?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2025-10-30T02:44:00Z', + type: 'User', + }, + { + name: 'Ryan Soderberg', + login: 'Ryanjso', + avatar: + 'https://avatars.githubusercontent.com/u/39172778?u=5ed913c31d57e7221b75784abcad48c7ebddde27&v=4', + link: 'https://github.com/Ryanjso?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2026-01-30T23:33:16Z', + type: 'User', + }, + { + name: 'shota', + login: 'itigoore01', + avatar: + 'https://avatars.githubusercontent.com/u/11831107?u=c976a6dc7e055eb026304c46c99100ed22b0c8e0&v=4', + link: 'https://github.com/itigoore01?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2026-05-21T00:28:12Z', + type: 'User', + }, + { + name: 'Nicholas', + login: 'Nic13Gamer', + avatar: + 'https://avatars.githubusercontent.com/u/54724556?u=56a7ab430ce7a80d648ab6eba051d454a818ed0b&v=4', + link: 'https://github.com/Nic13Gamer?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2026-06-28T20:17:54Z', + type: 'User', + }, + { + name: 'Ellis Driscoll', + login: 'ellis-driscoll', + avatar: + 'https://avatars.githubusercontent.com/u/70685966?u=c5f95bc33b5991d9744abe00052542e4a2ed3cb9&v=4', + link: 'https://github.com/ellis-driscoll?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2026-07-30T02:53:37Z', + type: 'User', + }, + { + name: 'Hoang Nguyen', + login: 'hoangbn', + avatar: + 'https://avatars.githubusercontent.com/u/38968280?u=c90084c6de65c56facabab7ba13a72a49ddbc3e4&v=4', + link: 'https://github.com/hoangbn?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2026-08-13T15:18:49Z', + type: 'User', + }, + { + name: 'Orestis Ioannou', + login: 'guest-ac41de3b', + avatar: 'https://images.opencollective.com/guest-ac41de3b/avatar/460.png', + link: 'https://opencollective.com/guest-ac41de3b?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2026-08-25T08:43:42.803Z', + type: 'User', + }, + { + name: 'Stefan Smiljkovic', + login: 'shtefcs', + avatar: + 'https://avatars.githubusercontent.com/u/1984909?u=b7bf5bc40ed49df3c22f69d2da7b8d78709c49ed&v=4', + link: 'https://automatio.ai/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Sponsor', + tierLevel: 2, + amount: 10, + createdAt: '2026-09-08T14:51:48Z', + type: 'User', + }, + { + name: 'David Walsh', + login: 'rhinodavid', + avatar: + 'https://avatars.githubusercontent.com/u/5778036?u=b5521f07d2f88c3db2a0dae62b5f2f8357214af0&v=4', + link: 'https://github.com/rhinodavid?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 8, + createdAt: '2025-08-07T03:26:10Z', + type: 'User', + }, + { + name: 'IPv4Addr', + login: 'IPv4Addr', + avatar: + 'https://avatars.githubusercontent.com/u/100147665?u=59996b72f69bb53063cb7e9ff8b8f898616cd94d&v=4', + link: 'https://github.com/IPv4Addr?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2025-03-12T10:09:06Z', + type: 'User', + }, + { + name: 'Robbe Vaes', + login: 'Robbe95', + avatar: + 'https://avatars.githubusercontent.com/u/44748019?u=e0232402c045ad4eac7cbd217f1f47e083103b89&v=4', + link: 'https://robbevaes.be/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2025-04-12T08:00:55Z', + type: 'User', + }, + { + name: 'Aidan Sunbury', + login: 'aidansunbury', + avatar: 'https://avatars.githubusercontent.com/u/64103161?v=4', + link: 'https://github.com/aidansunbury?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2025-05-20T05:38:01Z', + type: 'User', + }, + { + name: 'soonoo', + login: 'soonoo', + avatar: + 'https://avatars.githubusercontent.com/u/5436405?u=5d0b4aa955c87e30e6bda7f0cccae5402da99528&v=4', + link: 'https://github.com/soonoo?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2025-06-01T14:24:29Z', + type: 'User', + }, + { + name: 'Kevin Porten', + login: 'kporten', + avatar: + 'https://avatars.githubusercontent.com/u/1839345?u=dc2263d5cfe0d927ce1a0be04a1d55dd6b55405c&v=4', + link: 'https://kevinporten.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2025-10-03T12:33:00Z', + type: 'User', + }, + { + name: 'Denis', + login: 'pumpkinlink', + avatar: + 'https://avatars.githubusercontent.com/u/11864620?u=5f47bbe6c65d0f6f5cf011021490238e4b0593d0&v=4', + link: 'https://github.com/pumpkinlink?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2025-10-07T14:47:51Z', + type: 'User', + }, + { + name: 'Christopher Kapic', + login: 'christopher-kapic', + avatar: + 'https://avatars.githubusercontent.com/u/59740769?u=e7ad4b72b5bf6c9eb1644c26dbf3332a8f987377&v=4', + link: 'https://github.com/christopher-kapic?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2025-10-24T04:00:19Z', + type: 'User', + }, + { + name: 'Tom Ballinger', + login: 'thomasballinger', + avatar: + 'https://avatars.githubusercontent.com/u/458879?u=4b045ac75d721b6ac2b42a74d7d37f61f0414031&v=4', + link: 'http://ballingt.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2025-10-24T18:29:25Z', + type: 'User', + }, + { + name: 'Sam', + login: 'SSam0419', + avatar: + 'https://avatars.githubusercontent.com/u/102863520?u=3c89611f549d5070be232eb4532f690c8f2e7a65&v=4', + link: 'https://lee-sam.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2025-11-18T05:36:22Z', + type: 'User', + }, + { + name: 'Titoine', + login: 'Titoine', + avatar: + 'https://avatars.githubusercontent.com/u/3514286?u=1bb1e86b0c99c8a1121372e56d51a177eea12191&v=4', + link: 'https://github.com/Titoine?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2026-01-09T14:08:34Z', + type: 'User', + }, + { + name: 'Igor Makowski', + login: 'Mnigos', + avatar: + 'https://avatars.githubusercontent.com/u/56691628?u=ee8c879478f7c151b9156aef6c74243fa3e247a8&v=4', + link: 'https://rigtch.fm/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2026-01-19T21:33:37Z', + type: 'User', + }, + { + name: 'hanayashiki', + login: 'hanayashiki', + avatar: + 'https://avatars.githubusercontent.com/u/26056783?u=06c3b9205a16fd41a871e82da1cc2a09306d53f5&v=4', + link: 'https://blog.cwang.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2026-04-04T08:42:19Z', + type: 'User', + }, + { + name: 'Lev Dubinets', + login: 'ldub', + avatar: + 'https://avatars.githubusercontent.com/u/3114081?u=f547f5d5012cab54851f1b1ad72d10e537f78fc2&v=4', + link: 'https://dubinets.io/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2026-06-08T03:38:25Z', + type: 'User', + }, + { + name: 'Kelly Peilin Chan', + login: 'mr-kelly', + avatar: + 'https://avatars.githubusercontent.com/u/520852?u=6b0f7105f694e7b5cacf410a3f04c7044b469dc8&v=4', + link: 'https://kellychan.im/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2026-06-13T15:03:13Z', + type: 'User', + }, + { + name: 'Guy Ariely', + login: 'guyariely', + avatar: + 'https://avatars.githubusercontent.com/u/42813496?u=edb6b7f563bf28e160a290832e7da57c0506f8ca&v=4', + link: 'https://guyariely.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2026-08-01T14:25:58Z', + type: 'User', + }, + { + name: 'PaulSenon', + login: 'PaulSenon', + avatar: + 'https://avatars.githubusercontent.com/u/19531087?u=6385741eb91d200b8c513ec6045482e301767ca6&v=4', + link: 'https://paulsenon.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 5, + createdAt: '2026-08-30T03:12:32Z', + type: 'User', + }, + { + name: 'Alex', + login: 'piscis', + avatar: + 'https://avatars.githubusercontent.com/u/326163?u=b245f368bd940cf51d08c0b6bf55f8257f359437&v=4', + link: 'https://piscis.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 1, + createdAt: '2025-04-30T09:57:34Z', + type: 'User', + }, + { + name: 'Andrey Gubanov', + login: 'finom', + avatar: + 'https://avatars.githubusercontent.com/u/1082083?u=c5f2daf7ebece498e85c83367bb37b4e10e2649d&v=4', + link: 'https://opensource.gubanov.eu/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Backer', + tierLevel: 1, + amount: 1, + createdAt: '2026-05-29T20:29:45Z', + type: 'User', + }, + { + name: 'Maxie', + login: 'MrMaxie', + avatar: + 'https://avatars.githubusercontent.com/u/3857836?u=5e6b57973d4385d655663ffdd836e487856f2984&v=4', + link: 'https://maxie.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-03-11T02:22:27Z', + type: 'User', + }, + { + name: 'Zuplo', + login: 'zuplo', + avatar: 'https://avatars.githubusercontent.com/u/85497839?v=4', + link: 'https://zuplo.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-04-02T05:15:32Z', + type: 'Organization', + }, + { + name: 'motopods', + login: 'motopods', + avatar: 'https://avatars.githubusercontent.com/u/58200641?v=4', + link: 'https://github.com/motopods?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-04-09T14:23:49Z', + type: 'User', + }, + { + name: 'Francisco Hermida', + login: 'franciscohermida', + avatar: + 'https://avatars.githubusercontent.com/u/483242?u=bbcbc80eb9d8781ff401f7dafc3b59cd7bea0561&v=4', + link: 'https://github.com/franciscohermida?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-05-02T01:10:38Z', + type: 'User', + }, + { + name: 'Dexter Miguel', + login: 'divmgl', + avatar: + 'https://avatars.githubusercontent.com/u/5452298?u=645993204be8696c085ecf0d228c3062efe2ed65&v=4', + link: 'https://github.com/divmgl?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-05-02T03:29:29Z', + type: 'User', + }, + { + name: 'Théo LUDWIG', + login: 'theoludwig', + avatar: + 'https://avatars.githubusercontent.com/u/25207499?u=a6a9653725a2f574c07893748806668e0598cdbe&v=4', + link: 'https://theoludwig.fr/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-05-09T13:11:36Z', + type: 'User', + }, + { + name: 'Abhay Ramesh', + login: 'abhay-ramesh', + avatar: + 'https://avatars.githubusercontent.com/u/66196314?u=c5c2b0327b26606c2efcfaf17046ab18c3d25c57&v=4', + link: 'https://abhayramesh.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-05-10T17:41:05Z', + type: 'User', + }, + { + name: 'shr-ink', + login: 'shr-ink', + avatar: 'https://avatars.githubusercontent.com/u/139700438?v=4', + link: 'https://github.com/shr-ink?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-05-11T15:27:14Z', + type: 'Organization', + }, + { + name: '0x4e32', + login: 'johngerome', + avatar: + 'https://avatars.githubusercontent.com/u/2002000?u=505e54608466ab53754f702973687b04c6424c1f&v=4', + link: 'https://0x4e32.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-05-13T05:53:13Z', + type: 'User', + }, + { + name: 'ryuz', + login: 'ryuzdev', + avatar: + 'https://avatars.githubusercontent.com/u/196539378?u=d38374588d219b6748b16406982f6559411466d4&v=4', + link: 'https://github.com/ryuzdev?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-06-15T15:08:09Z', + type: 'User', + }, + { + name: 'happyboy', + login: 'happyboy2022', + avatar: + 'https://avatars.githubusercontent.com/u/103669586?u=65b49c4b893ed3703909fbb3a7a22313f3f9c121&v=4', + link: 'https://github.com/happyboy2022?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-07-01T16:25:19Z', + type: 'User', + }, + { + name: 'yicchi', + login: 'YiCChi', + avatar: + 'https://avatars.githubusercontent.com/u/86967274?u=6c2756f09fe15dd94d572f560e979cd157982852&v=4', + link: 'https://github.com/YiCChi?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-07-17T02:52:53Z', + type: 'User', + }, + { + name: 'Saksham', + login: 'cloudycotton', + avatar: + 'https://avatars.githubusercontent.com/u/168998965?u=9b9634a5aed66a51c1b880663272725b00b92b14&v=4', + link: 'https://saksham.work/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-07-22T21:32:51Z', + type: 'User', + }, + { + name: 'Roman Hrynevych', + login: 'hrynevychroman', + avatar: + 'https://avatars.githubusercontent.com/u/82209198?u=1a1d111ab3d589855b9cc8a7fefb1b5c6a4fbbaf&v=4', + link: 'https://hrynevych.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-07-23T12:14:14Z', + type: 'User', + }, + { + name: 'rokitg', + login: 'rokitgg', + avatar: + 'https://avatars.githubusercontent.com/u/125133357?u=06c74aefaa2236b06a2e5fba5a5c612339f45912&v=4', + link: 'https://github.com/rokitgg?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-08-06T09:38:53Z', + type: 'User', + }, + { + name: 'Omar Khatib', + login: 'omarkhatibgg', + avatar: + 'https://avatars.githubusercontent.com/u/9054278?u=afbba7331b85c51b8eee4130f5fd31b1017dc919&v=4', + link: 'http://khatib.gg/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-08-14T10:39:47Z', + type: 'User', + }, + { + name: 'Yu-Sabo', + login: 'YuSabo90002', + avatar: + 'https://avatars.githubusercontent.com/u/13120582?u=992c15f5438a3120e01f0497d45efb9e85c4994d&v=4', + link: 'https://github.com/YuSabo90002?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-08-20T12:50:33Z', + type: 'User', + }, + { + name: 'Bapusaheb Patil', + login: 'bapspatil', + avatar: + 'https://avatars.githubusercontent.com/u/16699418?u=6d9d8e0a64a6f91ca1c4d559c72d931172bdcbbd&v=4', + link: 'https://bapspatil.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-10-09T05:39:47Z', + type: 'User', + }, + { + name: 'grim', + login: 'ripgrim', + avatar: + 'https://avatars.githubusercontent.com/u/75869731?u=cc07a1b835267771bc03954ea0bb1446fe1208b5&v=4', + link: 'https://github.com/ripgrim?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-10-11T03:34:04Z', + type: 'User', + }, + { + name: 'Nelson Lai', + login: 'nelsonlaidev', + avatar: + 'https://avatars.githubusercontent.com/u/75498339?u=2fc0e0b95dd184c5ffb744df977cb15a18b60672&v=4', + link: 'https://nelsonlai.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-10-12T11:26:42Z', + type: 'User', + }, + { + name: 'Lê Cao Nguyên', + login: 'nguyenlc1993', + avatar: + 'https://avatars.githubusercontent.com/u/13871971?u=83c8b69d9e35b589c4e1f066cc113b1d9461386f&v=4', + link: 'https://github.com/nguyenlc1993?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-10-22T09:23:09Z', + type: 'User', + }, + { + name: 'Robert Soriano', + login: 'wobsoriano', + avatar: + 'https://avatars.githubusercontent.com/u/13049130?u=6d72104182e7c9ed25934815313fb69107332111&v=4', + link: 'https://robsoriano.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-10-25T02:59:20Z', + type: 'User', + }, + { + name: 'Andrew Peters', + login: 'andrewpeters9', + avatar: 'https://avatars.githubusercontent.com/u/36251325?v=4', + link: 'https://github.com/andrewpeters9?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-10-26T03:26:25Z', + type: 'User', + }, + { + name: 'Ryan Vogel', + login: 'R44VC0RP', + avatar: + 'https://avatars.githubusercontent.com/u/89211796?u=1857347b9787d8d8a7ea5bfc333f96be92d5a683&v=4', + link: 'https://ryan.ceo/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-10-26T15:57:48Z', + type: 'User', + }, + { + name: 'SKostyukovich', + login: 'SKostyukovich', + avatar: 'https://avatars.githubusercontent.com/u/10700067?v=4', + link: 'https://github.com/SKostyukovich?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-10-27T04:21:43Z', + type: 'User', + }, + { + name: 'Peter Adam', + login: 'peter-adam-dy', + avatar: + 'https://avatars.githubusercontent.com/u/132129459?u=4f3dbbb3b443990b56acb7d6a5d11ed2c555f6db&v=4', + link: 'https://github.com/peter-adam-dy?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-11-02T13:17:28Z', + type: 'User', + }, + { + name: 'Fabworks', + login: 'FabworksHQ', + avatar: 'https://avatars.githubusercontent.com/u/160179500?v=4', + link: 'https://fabworks.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-11-04T17:26:27Z', + type: 'Organization', + }, + { + name: 'Novak Antonijevic', + login: 'NovakAnton', + avatar: + 'https://avatars.githubusercontent.com/u/157126729?u=ae49fa22292d55c0434ff0ca008206155b18663b&v=4', + link: 'https://github.com/NovakAnton?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-11-15T10:28:15Z', + type: 'User', + }, + { + name: 'Laduni Estu Syalwa', + login: 'laduniestu', + avatar: + 'https://avatars.githubusercontent.com/u/44757637?u=a2fc1ea8f7d827a96721176f79d30592d1c48059&v=4', + link: 'https://dun.gg/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-11-17T14:33:48Z', + type: 'User', + }, + { + name: 'Chen, Zhi-Yuan', + login: 'yukimotochern', + avatar: + 'https://avatars.githubusercontent.com/u/20896173?u=945c33fc21725e4d566a0d02afc54b136ca1d67a&v=4', + link: 'https://github.com/yukimotochern?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2025-12-06T01:58:38Z', + type: 'User', + }, + { + name: 'Illarion Koperski', + login: 'illarionvk', + avatar: + 'https://avatars.githubusercontent.com/u/5012724?u=7cfa13652f7ac5fb3c56d880e3eb3fbe40c3ea34&v=4', + link: 'https://illarionvk.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-01-19T11:54:08Z', + type: 'User', + }, + { + name: 'Anees Iqbal', + login: 'steelbrain', + avatar: + 'https://avatars.githubusercontent.com/u/4278113?u=22b80b5399eed68ac76cd58b02961b0481f1db11&v=4', + link: 'https://aneesiqbal.ai/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-01-28T05:37:06Z', + type: 'User', + }, + { + name: 'Sefa Eyeoglu', + login: 'Scrumplex', + avatar: + 'https://avatars.githubusercontent.com/u/11587657?u=ab503582165c0bbff0cca47ce31c9450bb1553c9&v=4', + link: 'https://scrumplex.net/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-02-19T13:13:45Z', + type: 'User', + }, + { + name: 'natt', + login: 'nattstack', + avatar: + 'https://avatars.githubusercontent.com/u/31426677?u=fa9dbb8b3e66eb0ea3c88db5dc07f31c8c5418fe&v=4', + link: 'https://natt.so/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-04-23T23:23:31Z', + type: 'User', + }, + { + name: 'Adam Tkaczyk', + login: 'ChromeGG', + avatar: + 'https://avatars.githubusercontent.com/u/39050595?u=a58ca6042a6950e94e6e92442db76ef584279bc0&v=4', + link: 'https://tkaczyk.dev/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-05-15T07:51:35Z', + type: 'User', + }, + { + name: 'plancraft', + login: 'plancraft', + avatar: 'https://avatars.githubusercontent.com/u/46482287?v=4', + link: 'https://plancraft.com/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-05-18T11:16:16Z', + type: 'Organization', + }, + { + name: 'Guillermo Rauch', + login: 'rauchg', + avatar: + 'https://avatars.githubusercontent.com/u/13041?u=1ee8d111657cdd02ff6d253df00978d17ee6d722&v=4', + link: 'http://twitter.com/rauchg?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-09-09T19:11:39Z', + type: 'User', + }, + { + name: 'Nexa', + login: 'nexa-ca', + avatar: 'https://avatars.githubusercontent.com/u/199146462?v=4', + link: 'https://github.com/nexa-ca?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-09-21T17:06:10Z', + type: 'Organization', + }, + { + name: 'Laduni', + login: 'laduni', + avatar: 'https://images.opencollective.com/laduni/avatar/460.png', + link: 'https://opencollective.com/laduni?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-09-28T14:22:45.552Z', + type: 'User', + }, + { + name: 'Shotaro Nakamura', + login: 'nakasyou', + avatar: + 'https://avatars.githubusercontent.com/u/79000684?u=f644df3f29f0e8677a90967115774564f1d9d6ab&v=4', + link: 'https://nakasyou.how/?ref=middleapi&utm_source=middleapi&utm_medium=sponsor', + rel: 'sponsored', + tierTitle: 'Past Sponsor', + tierLevel: 0, + amount: -1, + createdAt: '2026-09-28T15:56:34Z', + type: 'User', + }, ] diff --git a/apps/content/theme.css b/apps/content/theme.css index 74ea080b62..d5314f78d7 100644 --- a/apps/content/theme.css +++ b/apps/content/theme.css @@ -149,7 +149,9 @@ layout, stretching the page sideways and adding thousands of pixels of phantom height below the content on narrow viewports. Keep them out of layout entirely until their trigger is hovered. */ -.prose .twoslash-hover:not(:hover):not(.twoslash-open) :is(.twoslash-popup-container, .twoslash-bridge) { +.prose + .twoslash-hover:not(:hover):not(.twoslash-open) + :is(.twoslash-popup-container, .twoslash-bridge) { display: none; } @@ -339,7 +341,10 @@ body > aside[data-blume-nav-drawer]:not([data-orpc-drawer]) { position: absolute; inset: 0; z-index: -1; - background-image: radial-gradient(color-mix(in oklch, var(--blume-foreground) 14%, transparent) 1px, transparent 1px); + background-image: radial-gradient( + color-mix(in oklch, var(--blume-foreground) 14%, transparent) 1px, + transparent 1px + ); background-size: 22px 22px; mask-image: radial-gradient(ellipse 75% 60% at 50% 0%, #000 10%, transparent 70%); pointer-events: none; @@ -576,7 +581,13 @@ body > aside[data-blume-nav-drawer]:not([data-orpc-drawer]) { } .orpc-tablist[data-overflow-start='true'][data-overflow-end='true'] { - mask-image: linear-gradient(to right, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); + mask-image: linear-gradient( + to right, + transparent, + #000 2.5rem, + #000 calc(100% - 2.5rem), + transparent + ); } .orpc-tablist::-webkit-scrollbar { diff --git a/apps/content/tsconfig.json b/apps/content/tsconfig.json index d94b10ccee..a4a14cfabb 100644 --- a/apps/content/tsconfig.json +++ b/apps/content/tsconfig.json @@ -1,6 +1,8 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../tsconfig.json", "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "preserve", "types": ["node"] }, "include": [ @@ -11,11 +13,5 @@ "sponsors", ".blume/.astro/types.d.ts" ], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "exclude": [] } diff --git a/apps/content/wrangler.jsonc b/apps/content/wrangler.jsonc index f6fa30f3c7..5760a333c1 100644 --- a/apps/content/wrangler.jsonc +++ b/apps/content/wrangler.jsonc @@ -1,6 +1,6 @@ { "name": "orpc", "assets": { - "html_handling": "drop-trailing-slash" - } + "html_handling": "drop-trailing-slash", + }, } diff --git a/benches/__shared__/payloads.ts b/benches/__shared__/payloads.ts index 54f29b72f3..9b40574fbe 100644 --- a/benches/__shared__/payloads.ts +++ b/benches/__shared__/payloads.ts @@ -11,7 +11,7 @@ export const handlers = { person: { condition: (value: unknown) => value instanceof Person, serialize: (person: Person) => ({ name: person.name, age: person.age }), - deserialize: (data: { name: string, age: number }) => new Person(data.name, data.age), + deserialize: (data: { name: string; age: number }) => new Person(data.name, data.age), }, } @@ -41,7 +41,7 @@ export const PAYLOAD_10KB = Array.from({ length: 10 }, (_, i) => createUnit(i)) export const PAYLOAD_100KB = Array.from({ length: 100 }, (_, i) => createUnit(i)) export const PAYLOAD_5MB = Array.from({ length: 5_000 }, (_, i) => createUnit(i)) // 3/10 JSON (native types + Person), 7/10 files -const FILE_BYTES = Math.floor(SIZE_5MB * 7 / 10 / 4) +const FILE_BYTES = Math.floor((SIZE_5MB * 7) / 10 / 4) export const PAYLOAD_5MB_WITH_FILES = { items: Array.from({ length: 1_500 }, (_, i) => createUnit(i)), files: [ @@ -54,16 +54,24 @@ export const PAYLOAD_5MB_WITH_FILES = { export const EVENTS_1KB = [PAYLOAD_1KB] export const EVENTS_10KB = Array.from({ length: 10 }).fill(PAYLOAD_1KB) -export const EVENTS_100KB = Array.from({ length: 50 }).fill([PAYLOAD_1KB, PAYLOAD_1KB]) -export const EVENTS_5MB = Array.from({ length: 1000 }).fill([PAYLOAD_1KB, PAYLOAD_1KB, PAYLOAD_1KB, PAYLOAD_1KB, PAYLOAD_1KB]) +export const EVENTS_100KB = Array.from({ length: 50 }, () => [PAYLOAD_1KB, PAYLOAD_1KB]) +export const EVENTS_5MB = Array.from({ length: 1000 }, () => [ + PAYLOAD_1KB, + PAYLOAD_1KB, + PAYLOAD_1KB, + PAYLOAD_1KB, + PAYLOAD_1KB, +]) /** Fresh async generator over prebuilt event parts (one-shot per call). */ -export function asSyncIteratorObject(parts: readonly unknown[]): AsyncGenerator { +export function asSyncIteratorObject( + parts: readonly unknown[], +): AsyncGenerator { return (async function* () { for (const part of parts) { yield part } - }()) + })() } export async function drainBody(body: unknown): Promise { @@ -91,8 +99,7 @@ export async function drainBody(body: unknown): Promise { break } } - } - finally { + } finally { reader.releaseLock() } } diff --git a/benches/json-schema-coercer.bench.ts b/benches/json-schema-coercer.bench.ts index 35b0a79322..f7048285ec 100644 --- a/benches/json-schema-coercer.bench.ts +++ b/benches/json-schema-coercer.bench.ts @@ -4,8 +4,16 @@ import { bench } from 'vitest' const coercer = new JsonSchemaCoercer() -const DATE_SCHEMA = { 'type': 'string', 'format': 'date-time', 'x-native-type': 'date' } as JsonSchema -const BIGINT_SCHEMA = { 'type': 'string', 'pattern': '^-?[0-9]+$', 'x-native-type': 'bigint' } as JsonSchema +const DATE_SCHEMA = { + 'type': 'string', + 'format': 'date-time', + 'x-native-type': 'date', +} as JsonSchema +const BIGINT_SCHEMA = { + 'type': 'string', + 'pattern': '^-?[0-9]+$', + 'x-native-type': 'bigint', +} as JsonSchema const URL_SCHEMA = { 'type': 'string', 'format': 'uri', 'x-native-type': 'url' } as JsonSchema /** What a GET request's query params look like: every leaf arrives as a string. */ @@ -46,7 +54,12 @@ const UNIT_SCHEMA: JsonSchema = { active: { type: 'boolean' }, createdAt: DATE_SCHEMA, largeInt: BIGINT_SCHEMA, - tags: { 'type': 'array', 'uniqueItems': true, 'items': { type: 'string' }, 'x-native-type': 'set' } as JsonSchema, + tags: { + 'type': 'array', + 'uniqueItems': true, + 'items': { type: 'string' }, + 'x-native-type': 'set', + } as JsonSchema, metadata: { 'type': 'array', 'items': { @@ -121,9 +134,8 @@ const TREE_SCHEMA: JsonSchema = { function createTree(depth: number, breadth: number): unknown { return { value: `${depth}`, - children: depth === 0 - ? [] - : Array.from({ length: breadth }, () => createTree(depth - 1, breadth)), + children: + depth === 0 ? [] : Array.from({ length: breadth }, () => createTree(depth - 1, breadth)), } } diff --git a/benches/openapi-link-handler.bench.ts b/benches/openapi-link-handler.bench.ts index 7b21bef617..60a98f0d5b 100644 --- a/benches/openapi-link-handler.bench.ts +++ b/benches/openapi-link-handler.bench.ts @@ -1,12 +1,19 @@ -import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { StandardLink } from '@orpc/client/standard' import { OpenAPISerializer } from '@orpc/openapi' import { OpenAPIHandlerCodec, OpenAPILinkCodec } from '@orpc/openapi/standard' +import type { RouterClient } from '@orpc/server' import { os, type } from '@orpc/server' import { StandardHandler } from '@orpc/server/standard' import { bench } from 'vitest' -import { asSyncIteratorObject, drainBody, EVENTS_10KB, handlers, PAYLOAD_10KB } from './__shared__/payloads' + +import { + asSyncIteratorObject, + drainBody, + EVENTS_10KB, + handlers, + PAYLOAD_10KB, +} from './__shared__/payloads.ts' const serializer = new OpenAPISerializer({ handlers }) @@ -42,8 +49,6 @@ describe('openapi link + handler', () => { }) bench('event stream', async () => { - await drainBody( - await client.ping(asSyncIteratorObject(EVENTS_10KB)), - ) + await drainBody(await client.ping(asSyncIteratorObject(EVENTS_10KB))) }) }) diff --git a/benches/openapi-serializer.bench.ts b/benches/openapi-serializer.bench.ts index 535c652114..ca9467f8f0 100644 --- a/benches/openapi-serializer.bench.ts +++ b/benches/openapi-serializer.bench.ts @@ -1,31 +1,30 @@ import { OpenAPISerializer } from '@orpc/openapi' import { bench } from 'vitest' -import { handlers, PAYLOAD_1KB, PAYLOAD_5MB, PAYLOAD_5MB_WITH_FILES, PAYLOAD_100KB } from './__shared__/payloads' + +import { + handlers, + PAYLOAD_1KB, + PAYLOAD_5MB, + PAYLOAD_5MB_WITH_FILES, + PAYLOAD_100KB, +} from './__shared__/payloads.ts' const serializer = new OpenAPISerializer({ handlers }) describe('openapi serializer', () => { bench('1KB payload', () => { - serializer.deserialize( - serializer.serialize(PAYLOAD_1KB), - ) + serializer.deserialize(serializer.serialize(PAYLOAD_1KB)) }) bench('100KB payload', () => { - serializer.deserialize( - serializer.serialize(PAYLOAD_100KB), - ) + serializer.deserialize(serializer.serialize(PAYLOAD_100KB)) }) bench('5MB payload', () => { - serializer.deserialize( - serializer.serialize(PAYLOAD_5MB), - ) + serializer.deserialize(serializer.serialize(PAYLOAD_5MB)) }) bench('5MB payload with files', () => { - serializer.deserialize( - serializer.serialize(PAYLOAD_5MB_WITH_FILES), - ) + serializer.deserialize(serializer.serialize(PAYLOAD_5MB_WITH_FILES)) }) }) diff --git a/benches/procedure-call.bench.ts b/benches/procedure-call.bench.ts index 69f166c7a3..7f9331fa0a 100644 --- a/benches/procedure-call.bench.ts +++ b/benches/procedure-call.bench.ts @@ -44,7 +44,7 @@ const fullClient = createProcedureClient(full, { describe('procedure call', () => { const input = { id: 1, - name: `1tem-${1}`, + name: `1tem-1`, active: true, createdAt: new Date('2024-01-01T00:00:00.000Z'), largeInt: 9007199254740993n + BigInt(1), diff --git a/benches/rpc-link-handler.bench.ts b/benches/rpc-link-handler.bench.ts index 298966ab80..129f0d1b62 100644 --- a/benches/rpc-link-handler.bench.ts +++ b/benches/rpc-link-handler.bench.ts @@ -1,10 +1,17 @@ -import type { RouterClient } from '@orpc/server' import { createORPCClient, RPCSerializer } from '@orpc/client' import { RPCLinkCodec, StandardLink } from '@orpc/client/standard' +import type { RouterClient } from '@orpc/server' import { os, type } from '@orpc/server' import { RPCHandlerCodec, StandardHandler } from '@orpc/server/standard' import { bench } from 'vitest' -import { asSyncIteratorObject, drainBody, EVENTS_10KB, handlers, PAYLOAD_10KB } from './__shared__/payloads' + +import { + asSyncIteratorObject, + drainBody, + EVENTS_10KB, + handlers, + PAYLOAD_10KB, +} from './__shared__/payloads.ts' const serializer = new RPCSerializer({ handlers }) @@ -40,8 +47,6 @@ describe('rpc link + handler', () => { }) bench('event stream', async () => { - await drainBody( - await client.ping(asSyncIteratorObject(EVENTS_10KB)), - ) + await drainBody(await client.ping(asSyncIteratorObject(EVENTS_10KB))) }) }) diff --git a/benches/rpc-serializer.bench.ts b/benches/rpc-serializer.bench.ts index 0762c28eb9..d15bad65a6 100644 --- a/benches/rpc-serializer.bench.ts +++ b/benches/rpc-serializer.bench.ts @@ -1,31 +1,30 @@ import { RPCSerializer } from '@orpc/client' import { bench } from 'vitest' -import { handlers, PAYLOAD_1KB, PAYLOAD_5MB, PAYLOAD_5MB_WITH_FILES, PAYLOAD_100KB } from './__shared__/payloads' + +import { + handlers, + PAYLOAD_1KB, + PAYLOAD_5MB, + PAYLOAD_5MB_WITH_FILES, + PAYLOAD_100KB, +} from './__shared__/payloads.ts' const serializer = new RPCSerializer({ handlers }) describe('rpc serializer', () => { bench('1KB payload', () => { - serializer.deserialize( - serializer.serialize(PAYLOAD_1KB), - ) + serializer.deserialize(serializer.serialize(PAYLOAD_1KB)) }) bench('100KB payload', () => { - serializer.deserialize( - serializer.serialize(PAYLOAD_100KB), - ) + serializer.deserialize(serializer.serialize(PAYLOAD_100KB)) }) bench('5MB payload', () => { - serializer.deserialize( - serializer.serialize(PAYLOAD_5MB), - ) + serializer.deserialize(serializer.serialize(PAYLOAD_5MB)) }) bench('5MB payload with files', () => { - serializer.deserialize( - serializer.serialize(PAYLOAD_5MB_WITH_FILES), - ) + serializer.deserialize(serializer.serialize(PAYLOAD_5MB_WITH_FILES)) }) }) diff --git a/benches/static-file-handler.bench.ts b/benches/static-file-handler.bench.ts index 9d4d900d1a..5d74498469 100644 --- a/benches/static-file-handler.bench.ts +++ b/benches/static-file-handler.bench.ts @@ -1,24 +1,32 @@ -import type { StandardLazyRequest } from '@standard-server/core' import { Buffer } from 'node:buffer' import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' + import { StaticFileHandlerPlugin } from '@orpc/node' import { RPCHandlerCodec, StandardHandler } from '@orpc/server/standard' +import type { StandardLazyRequest } from '@standard-server/core' import { bench } from 'vitest' -import { drainBody } from './__shared__/payloads' + +import { drainBody } from './__shared__/payloads.ts' const rootDir = mkdtempSync(path.join(tmpdir(), 'orpc-static-file-bench-')) writeFileSync(path.join(rootDir, 'file.txt'), Buffer.alloc(10 * 1024, 'a')) mkdirSync(path.join(rootDir, 'deeply', 'nested', 'dir'), { recursive: true }) -writeFileSync(path.join(rootDir, 'deeply', 'nested', 'dir', 'file.txt'), Buffer.alloc(10 * 1024, 'a')) +writeFileSync( + path.join(rootDir, 'deeply', 'nested', 'dir', 'file.txt'), + Buffer.alloc(10 * 1024, 'a'), +) const handler = new StandardHandler(new RPCHandlerCodec({}, {}), { plugins: [new StaticFileHandlerPlugin({ rootDir })], }) -function createRequest(url: `/${string}`, headers: Record = {}): StandardLazyRequest { +function createRequest( + url: `/${string}`, + headers: Record = {}, +): StandardLazyRequest { return { url, method: 'GET', @@ -38,12 +46,17 @@ describe('static file handler plugin', () => { }) bench('serve deeply nested encoded path', async () => { - const { response } = await handler.handle(createRequest('/deeply/nested/dir/file%2etxt'), { context: {} }) + const { response } = await handler.handle(createRequest('/deeply/nested/dir/file%2etxt'), { + context: {}, + }) await drainBody(response!.body) }) bench('range request', async () => { - const { response } = await handler.handle(createRequest('/file.txt', { range: 'bytes=0-1023' }), { context: {} }) + const { response } = await handler.handle( + createRequest('/file.txt', { range: 'bytes=0-1023' }), + { context: {} }, + ) await drainBody(response!.body) }) diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index d0a7cfceb1..0000000000 --- a/eslint.config.js +++ /dev/null @@ -1,123 +0,0 @@ -import antfu from '@antfu/eslint-config' -import pluginBan from 'eslint-plugin-ban' - -export default antfu({ - formatters: true, - ignores: [ - 'packages/cloudflare/worker-configuration.d.ts', - 'playgrounds/cloudflare/worker-configuration.d.ts', - ], - rules: { - 'pnpm/yaml-enforce-settings': 'off', - 'yaml/sort-keys': 'off', - 'jsdoc/no-defaults': 'off', - 'guard-for-in': 'error', - }, -}, { - plugins: { ban: pluginBan }, - rules: { - 'ts/consistent-type-definitions': 'off', - 'ts/method-signature-style': ['off'], - 'ban/ban': [ - 'error', - { - name: ['JSON', 'stringify'], - message: 'JSON.stringify can return undefined, use stringifyJSON instead', - }, - { - name: ['*', 'bytes'], - message: 'Request/Blob/Response/... .bytes is not widely supported, use readAsBuffer instead', - }, - { - name: ['AbortSignal', 'any'], - message: 'Use anyAbortSignal instead', - }, - { - name: ['*', 'throwIfAborted'], - message: 'React Native\'s AbortSignal polyfill has no throwIfAborted, use throwIfAborted from @orpc/shared instead', - }, - ], - 'no-restricted-imports': ['error', { - patterns: [{ - group: [ - '/@openapi-spec/types', - '/@standard-schema/spec', - '/compression', - ], - message: 'Please import from @orpc/* instead', - }], - }], - 'pnpm/json-enforce-catalog': 'off', - }, -}, { - files: ['packages/*/src/**'], - rules: { - // a scoped block replaces the base rule rather than extending it, - // so the first two names repeat what @antfu/eslint-config already bans - 'no-restricted-globals': ['error', { - name: 'global', - message: 'Use `globalThis` instead.', - }, { - name: 'self', - message: 'Use `globalThis` instead.', - }, { - name: 'AbortSignal', - message: 'AbortSignal is not a global in every runtime, read it only behind a typeof guard', - }, { - name: 'encodeURIComponent', - message: 'encodeURIComponent throws on lone surrogates, use safeEncodeURIComponent from @orpc/shared instead', - }, { - name: 'decodeURIComponent', - message: 'decodeURIComponent throws on malformed input, use safeDecodeURIComponent from @orpc/shared instead', - }], - }, -}, { - files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test-d.ts', '**/*.test-d.tsx', 'apps/content/shared/**', 'playgrounds/**', 'packages/*/playground/**'], - rules: { - 'unused-imports/no-unused-vars': 'off', - 'antfu/no-top-level-await': 'off', - 'no-alert': 'off', - 'ban/ban': 'off', - 'no-restricted-globals': 'off', - }, -}, { - files: [ - 'apps/content/shared/**', - 'apps/content/blog/**', - 'apps/content/docs/**', - 'apps/content/examples/**', - 'playgrounds/**', - 'packages/*/playground/**', - 'skills/**', - ], - rules: { - 'no-restricted-imports': 'off', - 'no-console': 'off', - 'perfectionist/sort-imports': 'off', - 'import/first': 'off', - 'ban/ban': 'off', - 'no-var': 'off', - 'vars-on-top': 'off', - '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/**'], - rules: { - 'import/first': 'off', - }, -}, { - files: ['playgrounds/**'], - rules: { - 'no-alert': 'off', - 'eslint-comments/no-unlimited-disable': 'off', - 'node/prefer-global/process': 'off', - }, -}, { - files: ['playgrounds/nest/**'], - rules: { - '@typescript-eslint/consistent-type-imports': 'off', - }, -}) diff --git a/oxfmt.config.ts b/oxfmt.config.ts new file mode 100644 index 0000000000..2bf0b68b40 --- /dev/null +++ b/oxfmt.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'oxfmt' + +export default defineConfig({ + semi: false, + singleQuote: true, + quoteProps: 'consistent', + sortImports: true, + ignorePatterns: ['.agents/skills/**', '.claude/skills/**'], +}) diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000000..0c511a8e0b --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,118 @@ +import { defineConfig } from 'oxlint' + +export default defineConfig({ + options: { + typeAware: true, + typeCheck: true, + }, + ignorePatterns: ['.agents/skills/**', '.claude/skills/**'], + categories: { + correctness: 'error', + suspicious: 'error', + }, + plugins: ['eslint', 'typescript', 'unicorn', 'oxc', 'import'], + rules: { + 'typescript/no-unsafe-type-assertion': 'off', + 'no-underscore-dangle': 'off', + // fixing it would change public types + 'typescript/no-generated-empty-object-type': 'off', + // its fixes break type checking on generic code + 'typescript/no-unnecessary-type-assertion': 'off', + 'typescript/unbound-method': 'off', + 'no-shadow': 'off', + + 'eqeqeq': 'error', + 'guard-for-in': 'error', + 'no-prototype-builtins': 'error', + 'no-throw-literal': 'error', + 'prefer-promise-reject-errors': 'error', + 'no-console': ['error', { allow: ['warn', 'error'] }], + 'no-var': 'error', + 'prefer-const': 'error', + 'typescript/no-import-type-side-effects': 'error', + 'unicorn/prefer-node-protocol': 'error', + 'no-restricted-globals': [ + 'error', + { + name: 'global', + message: 'Use `globalThis` instead.', + }, + { + name: 'self', + message: 'Use `globalThis` instead.', + }, + { + name: 'AbortSignal', + message: 'AbortSignal is not a global in every runtime, read it only behind a typeof guard', + }, + { + name: 'encodeURIComponent', + message: + 'encodeURIComponent throws on lone surrogates, use safeEncodeURIComponent from @orpc/shared instead', + }, + { + name: 'decodeURIComponent', + message: + 'decodeURIComponent throws on malformed input, use safeDecodeURIComponent from @orpc/shared instead', + }, + ], + 'no-restricted-properties': [ + 'error', + { + object: 'JSON', + property: 'stringify', + message: 'JSON.stringify can return undefined, use stringifyJSON instead', + }, + { + object: 'AbortSignal', + property: 'any', + message: 'Use anyAbortSignal instead', + }, + { + property: 'bytes', + message: + 'Request/Blob/Response/... .bytes is not widely supported, use readAsBuffer instead', + }, + { + property: 'throwIfAborted', + message: + "React Native's AbortSignal polyfill has no throwIfAborted, use throwIfAborted from @orpc/shared instead", + }, + ], + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@openapi-spec/types', '@openapi-spec/types/*', '@standard-schema/spec'], + message: 'Please import from @orpc/* instead', + }, + ], + }, + ], + }, + overrides: [ + { + files: [ + '**/*.test.ts', + '**/*.test.tsx', + '**/*.test-d.ts', + 'playgrounds/**', + 'apps/content/**', + ], + rules: { + 'no-unused-vars': 'off', + 'no-restricted-properties': 'off', + 'no-restricted-globals': 'off', + 'no-console': 'off', + 'no-shadow': 'off', + 'unicorn/consistent-function-scoping': 'off', + 'import/no-unassigned-import': 'off', + // intentional in test fixtures + 'require-yield': 'off', + 'no-unsafe-optional-chaining': 'off', + 'typescript/await-thenable': 'off', + }, + }, + ], +}) diff --git a/package.json b/package.json index d412792a10..aa782c7227 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,20 @@ { "name": "@orpc/monorepo", - "type": "module", "version": "2.0.0-beta.48", "private": true, - "devEngines": { - "packageManager": { - "name": "pnpm", - "version": "12.4.2", - "onFail": "download" - } - }, + "type": "module", "scripts": { - "prepare": "simple-git-hooks", - "type:check": "pnpm run -r type:check && tsc", - "bench": "vitest bench --run", + "check": "oxlint", + "fix": "oxlint --fix", + "format": "oxfmt", + "repo:fix": "sherif --fix -i typescript", "test": "pnpm run -r test && vitest run", "test:coverage": "pnpm run -r test:coverage && vitest run --coverage", - "lint": "eslint .", - "lint:fix": "eslint --fix .", - "repo:fix": "sherif --fix", + "bench": "vitest bench --run", "docs:validate": "node scripts/verify-docs-jsdoc.ts && pnpm run -r docs:validate", - "sponsors:sync": "node scripts/sync-sponsors.ts && eslint --fix **/README.md" + "sponsors:sync": "node scripts/sync-sponsors.ts && oxfmt '**/README.md' apps/content/sponsors" }, "devDependencies": { - "@antfu/eslint-config": "^9.5.1", "@codspeed/vitest-plugin": "^6.1.0", "@hono/node-server": "^2.1.6", "@opentelemetry/api": "^1.10.0", @@ -66,30 +57,23 @@ "bumpp": "^12.3.0", "changelogithub": "^15.0.4", "crossws": "^0.4.17", - "eslint": "^10.12.0", - "eslint-plugin-ban": "^2.0.0", - "eslint-plugin-format": "^2.0.1", "github-slugger": "^2.0.0", "jsdom": "^29.1.1", - "lint-staged": "^17.6.0", - "obuild": "^0.4.45", + "oxfmt": "^0.76.0", + "oxlint": "^1.91.0", + "oxlint-tsgolint": "^7.0.2005", "pino": "^10.4.0", "pkg-pr-new": "^0.0.93", "react": "^19.3.1", - "sherif": "^1.11.1", - "simple-git-hooks": "^2.14.0", + "sherif": "^1.13.0", "supertest": "^7.3.1", - "typescript": "^6.0.3", + "typescript": "^7.0.2", + "typescript-6": "npm:typescript@^6.0.3", "valibot": "^1.5.0", "vite": "^8.3.5", "vitest": "^4.1.11", "ws": "^8.22.1", "zod": "^4.6.7" }, - "simple-git-hooks": { - "pre-commit": "pnpm lint-staged" - }, - "lint-staged": { - "*": "eslint --no-warn-ignored --fix" - } + "packageManager": "pnpm@12.16.1" } diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index 3ffdcf8444..c5d7a85e5f 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/ai-sdk/build.config.ts b/packages/ai-sdk/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/ai-sdk/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index dbf144daa5..aa35befd80 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -1,53 +1,49 @@ { "name": "@orpc/ai-sdk", - "type": "module", "version": "2.0.0-beta.48", "description": "AI SDK integration for oRPC: turn contracts and procedures into typesafe AI SDK tools", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "agents", + "ai", + "ai-sdk", + "llm", + "orpc", + "tools", + "typesafe", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/ai-sdk" }, - "keywords": [ - "orpc", - "ai", - "ai-sdk", - "tools", - "llm", - "agents", - "typescript", - "typesafe" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "ai": ">=7.0.31" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", @@ -60,5 +56,8 @@ "devDependencies": { "ai": "^7.0.162", "zod": "^4.6.7" + }, + "peerDependencies": { + "ai": ">=7.0.31" } } diff --git a/packages/ai-sdk/src/index.test.ts b/packages/ai-sdk/src/index.test.ts index d6d1bc8789..64ecc8f18f 100644 --- a/packages/ai-sdk/src/index.test.ts +++ b/packages/ai-sdk/src/index.test.ts @@ -1,3 +1,3 @@ it('exports createToolFactory', async () => { - expect(Object.keys(await import('./index'))).toContain('createToolFactory') + expect(Object.keys(await import('./index.ts'))).toContain('createToolFactory') }) diff --git a/packages/ai-sdk/src/index.ts b/packages/ai-sdk/src/index.ts index 3ea9187bd9..83b12b4429 100644 --- a/packages/ai-sdk/src/index.ts +++ b/packages/ai-sdk/src/index.ts @@ -1,2 +1,2 @@ -export * from './tool' -export * from './tool-meta' +export * from './tool.ts' +export * from './tool-meta.ts' diff --git a/packages/ai-sdk/src/tool-meta.test-d.ts b/packages/ai-sdk/src/tool-meta.test-d.ts index 522938b68a..14556e5581 100644 --- a/packages/ai-sdk/src/tool-meta.test-d.ts +++ b/packages/ai-sdk/src/tool-meta.test-d.ts @@ -1,44 +1,47 @@ import { asyncIteratorObject, oc } from '@orpc/contract' import { os } from '@orpc/server' import { z } from 'zod' -import { aiSdkTool } from './tool-meta' + +import { aiSdkTool } from './tool-meta.ts' describe('aiSdkTool meta', () => { it('infer input and output types from the contract', () => { - oc - .input(z.object({ location: z.string() })) + oc.input(z.object({ location: z.string() })) .output(z.object({ temperature: z.number() })) - .meta(aiSdkTool({ - inputExamples: [{ input: { location: 'Hanoi' } }], - onInputAvailable: ({ input }) => { - expectTypeOf(input).toEqualTypeOf<{ location: string }>() - }, - toModelOutput: ({ input, output }) => { - expectTypeOf(input).toEqualTypeOf<{ location: string }>() - expectTypeOf(output).toEqualTypeOf<{ temperature: number }>() + .meta( + aiSdkTool({ + inputExamples: [{ input: { location: 'Hanoi' } }], + onInputAvailable: ({ input }) => { + expectTypeOf(input).toEqualTypeOf<{ location: string }>() + }, + toModelOutput: ({ input, output }) => { + expectTypeOf(input).toEqualTypeOf<{ location: string }>() + expectTypeOf(output).toEqualTypeOf<{ temperature: number }>() - return { type: 'text', value: `${output.temperature}` } - }, - })) + return { type: 'text', value: `${output.temperature}` } + }, + }), + ) - oc - .input(z.object({ location: z.string() })) - .meta(aiSdkTool({ + oc.input(z.object({ location: z.string() })).meta( + aiSdkTool({ // @ts-expect-error invalid input example inputExamples: [{ input: { location: 42 } }], - })) + }), + ) }) it('infer output as yield type for async iterator outputs', () => { - os - .input(z.object({ location: z.string() })) + os.input(z.object({ location: z.string() })) .output(asyncIteratorObject(z.object({ status: z.string() }))) - .meta(aiSdkTool({ - toModelOutput: ({ output }) => { - expectTypeOf(output).toEqualTypeOf<{ status: string }>() + .meta( + aiSdkTool({ + toModelOutput: ({ output }) => { + expectTypeOf(output).toEqualTypeOf<{ status: string }>() - return { type: 'text', value: output.status } - }, - })) + return { type: 'text', value: output.status } + }, + }), + ) }) }) diff --git a/packages/ai-sdk/src/tool-meta.test.ts b/packages/ai-sdk/src/tool-meta.test.ts index 42bfdce153..b6f7b0640d 100644 --- a/packages/ai-sdk/src/tool-meta.test.ts +++ b/packages/ai-sdk/src/tool-meta.test.ts @@ -1,5 +1,6 @@ import { oc } from '@orpc/contract' -import { aiSdkTool, getAiSdkToolMeta } from './tool-meta' + +import { aiSdkTool, getAiSdkToolMeta } from './tool-meta.ts' describe('aiSdkTool meta', () => { it('returns a plugin with name ~ai-sdk/tool', () => { diff --git a/packages/ai-sdk/src/tool-meta.ts b/packages/ai-sdk/src/tool-meta.ts index 1257311519..a159e70f62 100644 --- a/packages/ai-sdk/src/tool-meta.ts +++ b/packages/ai-sdk/src/tool-meta.ts @@ -1,14 +1,24 @@ -import type { AnyProcedureContract, AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, MetaPlugin } from '@orpc/contract' +import type { + AnyProcedureContract, + AnySchema, + ErrorMap, + InferSchemaInput, + InferSchemaOutput, + MetaPlugin, +} from '@orpc/contract' import type { Lazy } from '@orpc/server' import type { JSONValue, Tool } from 'ai' -import type { ToolOutput } from './tool' + +import type { ToolOutput } from './tool.ts' /** * The [function tool](https://ai-sdk.dev/docs/foundations/tools) member of the AI SDK `Tool` union, * which is the kind of tool this package produces. */ export type FunctionTool< + // oxlint-disable-next-line typescript/no-redundant-type-constituents INPUT extends JSONValue | unknown | never = any, + // oxlint-disable-next-line typescript/no-redundant-type-constituents OUTPUT extends JSONValue | unknown | never = any, > = Extract, { type?: 'function' | undefined }> @@ -24,10 +34,12 @@ export type FunctionTool< export type AiSdkToolMeta< TInputSchema extends AnySchema = AnySchema, TOutputSchema extends AnySchema = AnySchema, -> = Partial, ToolOutput>>, - 'inputSchema' | 'outputSchema' | 'execute' ->> +> = Partial< + Omit< + FunctionTool, ToolOutput>>, + 'inputSchema' | 'outputSchema' | 'execute' + > +> export interface AiSdkToolMetaPlugin< TInputSchema extends AnySchema, @@ -62,7 +74,7 @@ export interface AiSdkToolFunction { * * @see {@link https://orpc.dev/docs/integrations/ai-sdk#tool-implementer | AI SDK Integration - Tool Implementer} */ -export const aiSdkTool: AiSdkToolFunction = incoming => ({ +export const aiSdkTool: AiSdkToolFunction = (incoming) => ({ name: '~ai-sdk/tool', init(meta) { const existing = meta['~ai-sdk/tool'] as undefined | AiSdkToolMeta @@ -80,6 +92,8 @@ export const aiSdkTool: AiSdkToolFunction = incoming => ({ * * @see {@link https://orpc.dev/docs/integrations/ai-sdk#tool-implementer | AI SDK Integration - Tool Implementer} */ -export function getAiSdkToolMeta(procedureOrLazy: AnyProcedureContract | Lazy): AiSdkToolMeta | undefined { +export function getAiSdkToolMeta( + procedureOrLazy: AnyProcedureContract | Lazy, +): AiSdkToolMeta | undefined { return procedureOrLazy['~orpc'].meta['~ai-sdk/tool'] as AiSdkToolMeta | undefined } diff --git a/packages/ai-sdk/src/tool.test-d.ts b/packages/ai-sdk/src/tool.test-d.ts index 9f86ab46f8..cdb553bf92 100644 --- a/packages/ai-sdk/src/tool.test-d.ts +++ b/packages/ai-sdk/src/tool.test-d.ts @@ -1,20 +1,25 @@ -import type { InferToolInput, InferToolOutput } from 'ai' import { asyncIteratorObject, oc } from '@orpc/contract' import { os } from '@orpc/server' +import type { InferToolInput, InferToolOutput } from 'ai' import { generateText } from 'ai' import { z } from 'zod' -import { createToolFactory, implementToolFactory } from './tool' + +import { createToolFactory, implementToolFactory } from './tool.ts' describe('implementToolFactory', () => { it('can use as a tool', () => { const contract = oc - .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'), - })) + .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 = implementToolFactory()(contract, { execute: async ({ location }) => ({ @@ -34,12 +39,16 @@ describe('implementToolFactory', () => { it('infer correct input & output', () => { const contract = oc - .input(z.object({ - stringToNumber: z.string().transform(val => Number(val)), - })) - .output(z.object({ - numberToBoolean: z.number().transform(val => Boolean(val)), - })) + .input( + z.object({ + stringToNumber: z.string().transform((val) => Number(val)), + }), + ) + .output( + z.object({ + numberToBoolean: z.number().transform((val) => Boolean(val)), + }), + ) const tool = implementToolFactory()(contract, { execute: async ({ stringToNumber }) => { @@ -65,13 +74,17 @@ describe('implementToolFactory', () => { describe('createToolFactory', () => { it('can use as a tool', () => { const procedure = os - .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'), - })) + .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, @@ -92,12 +105,16 @@ describe('createToolFactory', () => { it('infer correct input & output', () => { const procedure = os - .input(z.object({ - stringToNumber: z.string().transform(val => Number(val)), - })) - .output(z.object({ - numberToBoolean: z.number().transform(val => Boolean(val)), - })) + .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, @@ -112,9 +129,11 @@ describe('createToolFactory', () => { it('execute is managed by the factory', () => { const procedure = os - .input(z.object({ - location: z.string(), - })) + .input( + z.object({ + location: z.string(), + }), + ) .handler(async () => 'output') void createToolFactory()(procedure, { @@ -126,9 +145,11 @@ describe('createToolFactory', () => { 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'), - })) + .input( + z.object({ + location: z.string().describe('The location to get the weather for'), + }), + ) .handler(async ({ context, input }) => {}) void createToolFactory({ @@ -150,10 +171,7 @@ describe('createToolFactory', () => { it('infer output as yield type for async iterator outputs', () => { const procedure = os .input(z.object({ location: z.string() })) - .output(asyncIteratorObject( - z.object({ status: z.string() }), - z.object({ url: z.string() }), - )) + .output(asyncIteratorObject(z.object({ status: z.string() }), z.object({ url: z.string() }))) .handler(async function* () { yield { status: 'building' } return { url: 'https://example.com' } @@ -165,12 +183,10 @@ describe('createToolFactory', () => { }) it('infer output as yield type for async generator handlers without an output schema', () => { - const procedure = os - .input(z.object({ location: z.string() })) - .handler(async function* () { - yield { status: 'building' } - return { url: 'https://example.com' } - }) + const procedure = os.input(z.object({ location: z.string() })).handler(async function* () { + yield { status: 'building' } + return { url: 'https://example.com' } + }) const tool = createToolFactory()(procedure) diff --git a/packages/ai-sdk/src/tool.test.ts b/packages/ai-sdk/src/tool.test.ts index 4cccafc77a..2d8f9060f8 100644 --- a/packages/ai-sdk/src/tool.test.ts +++ b/packages/ai-sdk/src/tool.test.ts @@ -3,9 +3,10 @@ import { OpenAPIJsonSerializer } from '@orpc/openapi' import { os } from '@orpc/server' import { generateText } from 'ai' import { MockLanguageModelV4 } from 'ai/test' -import z from 'zod' -import { createToolFactory, implementToolFactory } from './tool' -import { aiSdkTool } from './tool-meta' +import * as z from 'zod' + +import { aiSdkTool } from './tool-meta.ts' +import { createToolFactory, implementToolFactory } from './tool.ts' function toolCallModel(toolName: string, args: unknown) { return new MockLanguageModelV4({ @@ -60,7 +61,9 @@ describe('implementToolFactory', () => { const implementTool = implementToolFactory() const tool1 = implementTool(oc.input(inputSchema), { description: 'First tool' }) - const tool2 = implementTool(oc.input(inputSchema).output(outputSchema), { description: 'Second tool' }) + const tool2 = implementTool(oc.input(inputSchema).output(outputSchema), { + description: 'Second tool', + }) expect(tool1.description).toBe('First tool') expect(tool2.description).toBe('Second tool') @@ -69,9 +72,7 @@ describe('implementToolFactory', () => { it('support aiSdkTool meta to provide default tool options', () => { const contract = oc - .meta( - aiSdkTool({ metadata: { source: 'weather-service' }, description: 'Meta description' }), - ) + .meta(aiSdkTool({ metadata: { source: 'weather-service' }, description: 'Meta description' })) .input(inputSchema) const tool = implementToolFactory()(contract, { @@ -111,9 +112,12 @@ describe('implementToolFactory', () => { }) it('combines input schema fragments nested one level deep', async () => { - const contract = oc - .input(z.object({ params: z.object({ id: z.string() }) })) - .input(z.object({ params: z.object({ slug: z.string() }), query: z.object({ page: z.number() }) })) + const contract = oc.input(z.object({ params: z.object({ id: z.string() }) })).input( + z.object({ + params: z.object({ slug: z.string() }), + query: z.object({ page: z.number() }), + }), + ) const tool = implementToolFactory()(contract) const combined = tool.inputSchema as any @@ -139,28 +143,26 @@ describe('implementToolFactory', () => { const tool = implementToolFactory()(contract) const combined = tool.inputSchema as any - await expect( - combined['~standard'].validate({ id: '5', name: 'NAME' }), - ).resolves.toEqual({ value: { id: 5, name: 'NAME' } }) + await expect(combined['~standard'].validate({ id: '5', name: 'NAME' })).resolves.toEqual({ + value: { id: 5, name: 'NAME' }, + }) }) it('combines non-object input schemas by piping validation in order', async () => { const contract = oc - .input(type(value => `first__${value}`)) - .input(type(value => `second__${value}`)) + .input(type((value) => `first__${value}`)) + .input(type((value) => `second__${value}`)) const tool = implementToolFactory()(contract) const combined = tool.inputSchema as any - await expect( - combined['~standard'].validate('INPUT'), - ).resolves.toEqual({ value: 'second__first__INPUT' }) + await expect(combined['~standard'].validate('INPUT')).resolves.toEqual({ + value: 'second__first__INPUT', + }) }) it('combines input json schemas with allOf and hoists $schema to the root', () => { - const contract = oc - .input(z.object({ name: z.string() })) - .input(extraInputSchema) + const contract = oc.input(z.object({ name: z.string() })).input(extraInputSchema) const tool = implementToolFactory()(contract) const combined = tool.inputSchema as any @@ -181,27 +183,32 @@ describe('implementToolFactory', () => { }) it('promotes $defs to the root and rewrites $ref pointers', () => { - const jsonSchemaSupportedSchema = (jsonSchema: Record) => ({ - '~standard': { - vendor: 'custom', - version: 1, - validate: (value: unknown) => ({ value }), - jsonSchema: { - input: () => jsonSchema, - output: () => jsonSchema, + const jsonSchemaSupportedSchema = (jsonSchema: Record) => + ({ + '~standard': { + vendor: 'custom', + version: 1, + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => jsonSchema, + output: () => jsonSchema, + }, }, - }, - }) as any + }) as any const contract = oc - .input(jsonSchemaSupportedSchema({ - $defs: { user: { type: 'object' } }, - $ref: '#/$defs/user', - })) - .input(jsonSchemaSupportedSchema({ - $defs: { user: { type: 'string' } }, - properties: { friend: { $ref: '#/$defs/user' }, self: { $ref: '#' } }, - })) + .input( + jsonSchemaSupportedSchema({ + $defs: { user: { type: 'object' } }, + $ref: '#/$defs/user', + }), + ) + .input( + jsonSchemaSupportedSchema({ + $defs: { user: { type: 'string' } }, + properties: { friend: { $ref: '#/$defs/user' }, self: { $ref: '#' } }, + }), + ) const tool = implementToolFactory()(contract) const combined = tool.inputSchema as any @@ -219,9 +226,7 @@ describe('implementToolFactory', () => { }) it('converts json schema using only the schemas that support it', () => { - const contract = oc - .input(z.object({ name: z.string() })) - .input(type<{ age: number }>()) + const contract = oc.input(z.object({ name: z.string() })).input(type<{ age: number }>()) const tool = implementToolFactory()(contract) const combined = tool.inputSchema as any @@ -232,9 +237,7 @@ describe('implementToolFactory', () => { }) it('omits json schema conversion when no schema supports it', () => { - const contract = oc - .input(type<{ name: string }>()) - .input(type<{ age: number }>()) + const contract = oc.input(type<{ name: string }>()).input(type<{ age: number }>()) const tool = implementToolFactory()(contract) const combined = tool.inputSchema as any @@ -247,21 +250,25 @@ describe('implementToolFactory', () => { const contract = oc .input(inputSchema) - .output(type<{ greeting: string }>((value) => { - order.push('first') - return value - })) - .output(type<{ greeting: string }>((value) => { - order.push('second') - return value - })) + .output( + type<{ greeting: string }>((value) => { + order.push('first') + return value + }), + ) + .output( + type<{ greeting: string }>((value) => { + order.push('second') + return value + }), + ) const tool = implementToolFactory()(contract) const combined = tool.outputSchema as any - await expect( - combined['~standard'].validate({ greeting: 'Hello, Alice!' }), - ).resolves.toEqual({ value: { greeting: 'Hello, Alice!' } }) + await expect(combined['~standard'].validate({ greeting: 'Hello, Alice!' })).resolves.toEqual({ + value: { greeting: 'Hello, Alice!' }, + }) expect(order).toEqual(['second', 'first']) }) @@ -280,9 +287,7 @@ describe('implementToolFactory', () => { prompt: 'Greet Alice', }) - expect(result.toolResults).toEqual([ - expect.objectContaining({ output: { greeting: 123 } }), - ]) + expect(result.toolResults).toEqual([expect.objectContaining({ output: { greeting: 123 } })]) }) describe('async iterator output schema', () => { @@ -291,23 +296,33 @@ describe('implementToolFactory', () => { it('use yield schema as output schema', () => { expect( - implementToolFactory()(oc.input(inputSchema).output(asyncIteratorObject(yieldSchema))).outputSchema, + implementToolFactory()(oc.input(inputSchema).output(asyncIteratorObject(yieldSchema))) + .outputSchema, ).toBe(yieldSchema) expect( - implementToolFactory()(oc.input(inputSchema).output(asyncIteratorObject(yieldSchema, returnSchema))).outputSchema, + implementToolFactory()( + oc.input(inputSchema).output(asyncIteratorObject(yieldSchema, returnSchema)), + ).outputSchema, ).toBe(yieldSchema) }) }) describe('model output', () => { - const toModelOutput = (tool: any, output: unknown) => tool.toModelOutput({ toolCallId: 'call-1', input: {}, output }) + const toModelOutput = (tool: any, output: unknown) => + tool.toModelOutput({ toolCallId: 'call-1', input: {}, output }) const pdfBase64 = btoa('pdf') it('serializes outputs like OpenAPI responses', async () => { const tool = implementToolFactory()(oc) - await expect(toModelOutput(tool, { date: new Date('2024-01-01T00:00:00.000Z'), big: 1n, set: new Set([1, 2]) })).resolves.toEqual({ + await expect( + toModelOutput(tool, { + date: new Date('2024-01-01T00:00:00.000Z'), + big: 1n, + set: new Set([1, 2]), + }), + ).resolves.toEqual({ type: 'json', value: { date: '2024-01-01T00:00:00.000Z', big: '1', set: [1, 2] }, }) @@ -321,19 +336,37 @@ describe('implementToolFactory', () => { await expect(toModelOutput(tool, pdf)).resolves.toEqual({ type: 'content', - value: [{ type: 'file', mediaType: 'application/pdf', data: { type: 'data', data: pdfBase64 }, filename: 'q3.pdf' }], + value: [ + { + type: 'file', + mediaType: 'application/pdf', + data: { type: 'data', data: pdfBase64 }, + filename: 'q3.pdf', + }, + ], }) await expect(toModelOutput(tool, new Blob(['bytes']))).resolves.toEqual({ type: 'content', - value: [{ type: 'file', mediaType: 'application/octet-stream', data: { type: 'data', data: btoa('bytes') } }], + value: [ + { + type: 'file', + mediaType: 'application/octet-stream', + data: { type: 'data', data: btoa('bytes') }, + }, + ], }) await expect(toModelOutput(tool, { title: 'Q3', files: [pdf] })).resolves.toEqual({ type: 'content', value: [ { type: 'text', text: '{"title":"Q3","files":[{}]}' }, - { type: 'file', mediaType: 'application/pdf', data: { type: 'data', data: pdfBase64 }, filename: 'files/0/q3.pdf' }, + { + type: 'file', + mediaType: 'application/pdf', + data: { type: 'data', data: pdfBase64 }, + filename: 'files/0/q3.pdf', + }, ], }) }) @@ -344,10 +377,18 @@ describe('implementToolFactory', () => { } const serializer = new OpenAPIJsonSerializer({ - handlers: { money: { condition: data => data instanceof Money, serialize: (data: Money) => `$${data.cents / 100}`, isTerminal: true } }, + handlers: { + money: { + condition: (data) => data instanceof Money, + serialize: (data: Money) => `$${data.cents / 100}`, + isTerminal: true, + }, + }, }) - await expect(toModelOutput(implementToolFactory({ serializer })(oc), { price: new Money(150) })).resolves.toEqual({ + await expect( + toModelOutput(implementToolFactory({ serializer })(oc), { price: new Money(150) }), + ).resolves.toEqual({ type: 'json', value: { price: '$1.5' }, }) @@ -357,7 +398,9 @@ describe('implementToolFactory', () => { const custom = () => ({ type: 'text' as const, value: 'custom' }) expect(implementToolFactory()(oc, { toModelOutput: custom }).toModelOutput).toBe(custom) - expect(implementToolFactory()(oc.meta(aiSdkTool({ toModelOutput: custom }))).toModelOutput).toBe(custom) + expect( + implementToolFactory()(oc.meta(aiSdkTool({ toModelOutput: custom }))).toModelOutput, + ).toBe(custom) }) it('sends files to the model through the AI SDK', async () => { @@ -365,17 +408,33 @@ describe('implementToolFactory', () => { execute: async () => new File(['pdf'], 'q3.pdf', { type: 'application/pdf' }), }) - const result = await generateText({ model: toolCallModel('report', {}), tools: { report }, prompt: 'Report' }) + const result = await generateText({ + model: toolCallModel('report', {}), + tools: { report }, + prompt: 'Report', + }) - expect(result.response.messages.at(-1)?.content).toEqual([expect.objectContaining({ - output: { type: 'content', value: [{ type: 'file', mediaType: 'application/pdf', data: { type: 'data', data: pdfBase64 }, filename: 'q3.pdf' }] }, - })]) + expect(result.response.messages.at(-1)?.content).toEqual([ + expect.objectContaining({ + output: { + type: 'content', + value: [ + { + type: 'file', + mediaType: 'application/pdf', + data: { type: 'data', data: pdfBase64 }, + filename: 'q3.pdf', + }, + ], + }, + }), + ]) }) }) }) describe('createToolFactory', () => { - const abortSignal = (new AbortController()).signal + const abortSignal = new AbortController().signal const inputSchema = z.object({ name: z.string().describe('Name of the person'), @@ -406,13 +465,18 @@ describe('createToolFactory', () => { 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!' }) + 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' }, - }), { name: 'Alice' }) + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + signal: abortSignal, + input: { name: 'Alice' }, + context: { authToken: 'auth-token' }, + }), + { name: 'Alice' }, + ) }) it('always passes the tool call signal to the procedure', async () => { @@ -426,17 +490,18 @@ describe('createToolFactory', () => { return { greeting: `Hello, ${input.name}!` } }) - const streamingProcedure = os - .input(inputSchema) - .handler(async function* ({ input, signal }) { - signals.push(signal) - yield { greeting: `Hello, ${input.name}!` } - }) + const streamingProcedure = os.input(inputSchema).handler(async function* ({ input, signal }) { + signals.push(signal) + yield { greeting: `Hello, ${input.name}!` } + }) const createTool = createToolFactory({ signal: undefined } as any) await (createTool(procedure) as any).execute({ name: 'Alice' }, { abortSignal }) - for await (const _ of (createTool(streamingProcedure) as any).execute({ name: 'Alice' }, { abortSignal })) { + for await (const _ of (createTool(streamingProcedure) as any).execute( + { name: 'Alice' }, + { abortSignal }, + )) { // consume } @@ -463,14 +528,13 @@ describe('createToolFactory', () => { it('disable input validation at oRPC level to avoid validating twice', async () => { const handler = vi.fn(() => ({ greeting: 'Hello!' })) - const procedure = os - .input(inputSchema) - .output(outputSchema) - .handler(handler) + const procedure = os.input(inputSchema).output(outputSchema).handler(handler) const tool = createToolFactory()(procedure) - await expect(tool.execute?.('invalid' as any, { abortSignal } as any)).resolves.toEqual({ greeting: 'Hello!' }) + await expect(tool.execute?.('invalid' as any, { abortSignal } as any)).resolves.toEqual({ + greeting: 'Hello!', + }) expect(handler).toHaveBeenCalledWith(expect.objectContaining({ input: 'invalid' }), 'invalid') }) @@ -483,7 +547,9 @@ describe('createToolFactory', () => { const tool = createToolFactory()(procedure) - await expect(tool.execute?.({ name: 'Alice' }, { abortSignal } as any)).rejects.toThrow('Output validation failed') + await expect(tool.execute?.({ name: 'Alice' }, { abortSignal } as any)).rejects.toThrow( + 'Output validation failed', + ) }) describe('async iterator output', () => { @@ -520,8 +586,7 @@ describe('createToolFactory', () => { try { yield { message: 'one' } yield { message: 'two' } - } - finally { + } finally { finallyCalled = true } }) @@ -564,13 +629,11 @@ describe('createToolFactory', () => { }) it('streams events when the handler is an async generator without an output schema', async () => { - const procedure = os - .input(inputSchema) - .handler(async function* ({ input }) { - yield { message: `one ${input.name}` } - yield { message: `two ${input.name}` } - return { count: 2 } - }) + const procedure = os.input(inputSchema).handler(async function* ({ input }) { + yield { message: `one ${input.name}` } + yield { message: `two ${input.name}` } + return { count: 2 } + }) const tool = createToolFactory()(procedure) @@ -604,11 +667,11 @@ describe('createToolFactory', () => { }) it('does not stream when a non-generator handler returns an async iterator without an asyncIteratorObject schema', async () => { - const procedure = os - .input(inputSchema) - .handler(async () => (async function* () { + const procedure = os.input(inputSchema).handler(async () => + (async function* () { yield { message: 'one' } - })()) + })(), + ) const tool = createToolFactory()(procedure) diff --git a/packages/ai-sdk/src/tool.ts b/packages/ai-sdk/src/tool.ts index c8e3820b46..b746b23e75 100644 --- a/packages/ai-sdk/src/tool.ts +++ b/packages/ai-sdk/src/tool.ts @@ -1,17 +1,33 @@ import type { ClientOptions } from '@orpc/client' -import type { AnySchema, InferSchemaInput, InferSchemaOutput, ProcedureContract, Schema } from '@orpc/contract' -import type { JsonSchema } from '@orpc/json-schema' -import type { Context, ProcedureClientOptions } from '@orpc/server' -import type { MaybeOptionalOptions, Public } from '@orpc/shared' -import type { FlexibleSchema, JSONValue, Tool, ToolResultPart } from 'ai' -import type { FunctionTool } from './tool-meta' +import type { + AnySchema, + InferSchemaInput, + InferSchemaOutput, + ProcedureContract, + Schema, +} from '@orpc/contract' import { getAsyncIteratorObjectSchemasDetails } from '@orpc/contract' +import type { JsonSchema } from '@orpc/json-schema' import { combineJsonSchemasWithComposition } from '@orpc/json-schema' import { OpenAPIJsonSerializer } from '@orpc/openapi' +import type { Context, ProcedureClientOptions } from '@orpc/server' import { call, Procedure } from '@orpc/server' -import { encodeBase64, isAsyncGeneratorFunction, loadBytes, mergeTwoLevels, ORPC_NAME, resolveMaybeOptionalOptions, stringifyJSON, toArray } from '@orpc/shared' +import type { MaybeOptionalOptions, Public } from '@orpc/shared' +import { + encodeBase64, + isAsyncGeneratorFunction, + loadBytes, + mergeTwoLevels, + ORPC_NAME, + resolveMaybeOptionalOptions, + stringifyJSON, + toArray, +} from '@orpc/shared' +import type { FlexibleSchema, JSONValue, Tool, ToolResultPart } from 'ai' import { tool } from 'ai' -import { getAiSdkToolMeta } from './tool-meta' + +import type { FunctionTool } from './tool-meta.ts' +import { getAiSdkToolMeta } from './tool-meta.ts' const ANY_SCHEMA: FlexibleSchema = { '~standard': { @@ -38,7 +54,10 @@ function combineJsonSchemas(jsonSchemas: Record[]): Record + const combined = combineJsonSchemasWithComposition('allOf', branches as JsonSchema[]) as Record< + string, + unknown + > return { $schema, ...combined } } @@ -52,15 +71,23 @@ function combineSchemas(schemas: AnySchema[], merge: boolean): undefined | Flexi return schemas[0]! } - type MaybeStandardJsonSchema = AnySchema & Extract + type MaybeStandardJsonSchema = AnySchema & + Extract - const standardJsonSchemas = (schemas as MaybeStandardJsonSchema[]) - .filter(schema => schema['~standard'].jsonSchema) + const standardJsonSchemas = (schemas as MaybeStandardJsonSchema[]).filter( + (schema) => schema['~standard'].jsonSchema, + ) const jsonSchema: MaybeStandardJsonSchema['~standard']['jsonSchema'] = standardJsonSchemas.length ? { - input: options => combineJsonSchemas(standardJsonSchemas.map(schema => schema['~standard'].jsonSchema!.input(options))), - output: options => combineJsonSchemas(standardJsonSchemas.map(schema => schema['~standard'].jsonSchema!.output(options))), + input: (options) => + combineJsonSchemas( + standardJsonSchemas.map((schema) => schema['~standard'].jsonSchema!.input(options)), + ), + output: (options) => + combineJsonSchemas( + standardJsonSchemas.map((schema) => schema['~standard'].jsonSchema!.output(options)), + ), } : undefined @@ -74,7 +101,9 @@ function combineSchemas(schemas: AnySchema[], merge: boolean): undefined | Flexi // Mirrors the server's stacked input validation, output schemas stay piped. for (const [index, schema] of schemas.entries()) { const merging = merge && index !== 0 - const result = await schema['~standard'].validate(merging ? mergeTwoLevels(value, current) : current) + const result = await schema['~standard'].validate( + merging ? mergeTwoLevels(value, current) : current, + ) if (result.issues) { return result @@ -92,6 +121,7 @@ function combineSchemas(schemas: AnySchema[], merge: boolean): undefined | Flexi function combineOutputSchemas(outputSchemas: AnySchema[]): FlexibleSchema | undefined { const yieldSchemas = getAsyncIteratorObjectSchemasDetails(outputSchemas)?.yieldSchemas + // oxlint-disable-next-line unicorn/no-array-reverse return combineSchemas([...(yieldSchemas ?? outputSchemas)].reverse(), false) } @@ -99,7 +129,10 @@ function combineOutputSchemas(outputSchemas: AnySchema[]): FlexibleSchema | unde * Serializes an output like an OpenAPI response body. JSON shows a `Blob` as an empty object, * so blobs, nested or not, are sent as file parts named by their path, such as `report/q3.pdf`. */ -async function toModelOutput(serializer: Public, output: unknown): Promise { +async function toModelOutput( + serializer: Public, + output: unknown, +): Promise { const { json, maps, blobs } = serializer.serializeJson(output) if (typeof json === 'string') { @@ -110,20 +143,25 @@ async function toModelOutput(serializer: Public, output: return { type: 'json', value: (json ?? null) as JSONValue } } - const files = await Promise.all(blobs.map(async (blob, i) => { - const path = maps[i]! - const names = blob instanceof File && blob.name !== '' ? [...path, blob.name] : path - - return { - type: 'file' as const, - mediaType: blob.type || 'application/octet-stream', - // base64 is encoded once, providers would otherwise re-encode the bytes on every step - data: { type: 'data' as const, data: encodeBase64(await loadBytes(blob)) }, - ...(names.length === 0 ? {} : { filename: names.join('/') }), - } - })) + const files = await Promise.all( + blobs.map(async (blob, i) => { + const path = maps[i]! + const names = blob instanceof File && blob.name !== '' ? [...path, blob.name] : path + + return { + type: 'file' as const, + mediaType: blob.type || 'application/octet-stream', + // base64 is encoded once, providers would otherwise re-encode the bytes on every step + data: { type: 'data' as const, data: encodeBase64(await loadBytes(blob)) }, + ...(names.length === 0 ? {} : { filename: names.join('/') }), + } + }), + ) - return { type: 'content', value: json instanceof Blob ? files : [{ type: 'text', text: stringifyJSON(json)! }, ...files] } + return { + type: 'content', + value: json instanceof Blob ? files : [{ type: 'text', text: stringifyJSON(json)! }, ...files], + } } /** @@ -135,9 +173,7 @@ async function toModelOutput(serializer: Public, output: * * @see {@link https://orpc.dev/docs/integrations/ai-sdk#streaming-tool-outputs | AI SDK Integration - Streaming Tool Outputs} */ -export type ToolOutput = T extends AsyncIteratorObject - ? TYield - : T +export type ToolOutput = T extends AsyncIteratorObject ? TYield : T export interface ImplementToolFactoryOptions { /** @@ -153,7 +189,12 @@ export interface ImplementToolFactoryOptions { export interface ToolImplementer { ( contract: ProcedureContract, - ...rest: MaybeOptionalOptions, ToolOutput>>, 'inputSchema' | 'outputSchema'>> + ...rest: MaybeOptionalOptions< + Omit< + FunctionTool, ToolOutput>>, + 'inputSchema' | 'outputSchema' + > + > ): Tool, ToolOutput>> } @@ -202,7 +243,8 @@ export interface ToolImplementer { */ export function implementToolFactory(options: ImplementToolFactoryOptions = {}): ToolImplementer { const serializer = options.serializer ?? new OpenAPIJsonSerializer() - const defaultToModelOutput: NonNullable = ({ output }) => toModelOutput(serializer, output) + const defaultToModelOutput: NonNullable = ({ output }) => + toModelOutput(serializer, output) const factory: ToolImplementer = (contract, ...rest) => { const toolOptions = resolveMaybeOptionalOptions(rest) @@ -224,15 +266,20 @@ export function implementToolFactory(options: ImplementToolFactoryOptions = {}): return factory } -export type CreateToolFactoryOptions - = & ImplementToolFactoryOptions - & ProcedureClientOptions, object, object> - & Omit, 'context' | 'signal'> +export type CreateToolFactoryOptions = + ImplementToolFactoryOptions & + ProcedureClientOptions, object, object> & + Omit, 'context' | 'signal'> export interface ToolFactory { ( procedure: Procedure, - ...rest: MaybeOptionalOptions, ToolOutput>>, 'inputSchema' | 'outputSchema' | 'execute'>> + ...rest: MaybeOptionalOptions< + Omit< + FunctionTool, ToolOutput>>, + 'inputSchema' | 'outputSchema' | 'execute' + > + > ): Tool, ToolOutput>> } @@ -299,11 +346,12 @@ export function createToolFactory( * Output schemas are the source of truth, but an `async function*` handler always * returns an async iterator, so it streams even without an `asyncIteratorObject` schema. */ - const isIteratorOutput = getAsyncIteratorObjectSchemasDetails(procedure['~orpc'].outputSchemas) !== undefined - || isAsyncGeneratorFunction(procedure['~orpc'].handler) + const isIteratorOutput = + getAsyncIteratorObjectSchemasDetails(procedure['~orpc'].outputSchemas) !== undefined || + isAsyncGeneratorFunction(procedure['~orpc'].handler) return implementTool(procedure, { - ...toolOptions as any, + ...(toolOptions as any), /** * For async iterator outputs, the tool streams each event as a * [preliminary result](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#preliminary-tool-results), @@ -311,10 +359,16 @@ export function createToolFactory( */ execute: isIteratorOutput ? async function* (input, callingOptions) { - yield* await call(disabledValidation, input as any, { ...options, signal: callingOptions.abortSignal }) as AsyncIterable - } + yield* (await call(disabledValidation, input as any, { + ...options, + signal: callingOptions.abortSignal, + })) as AsyncIterable + } : (input, callingOptions) => { - return call(disabledValidation, input as any, { ...options, signal: callingOptions.abortSignal }) + return call(disabledValidation, input as any, { + ...options, + signal: callingOptions.abortSignal, + }) }, }) as any } diff --git a/packages/ai-sdk/tsconfig.json b/packages/ai-sdk/tsconfig.json index 7a3f25403a..9979a0715c 100644 --- a/packages/ai-sdk/tsconfig.json +++ b/packages/ai-sdk/tsconfig.json @@ -1,19 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../contract" }, - { "path": "../json-schema" }, - { "path": "../openapi" }, - { "path": "../server" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/arktype/README.md b/packages/arktype/README.md index 6d2520b635..4c7c08360f 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/arktype/build.config.ts b/packages/arktype/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/arktype/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/arktype/package.json b/packages/arktype/package.json index 89403e3593..5f90271d9c 100644 --- a/packages/arktype/package.json +++ b/packages/arktype/package.json @@ -1,53 +1,48 @@ { "name": "@orpc/arktype", - "type": "module", "version": "2.0.0-beta.48", "description": "ArkType integration for oRPC: convert ArkType schemas into JSON Schema for OpenAPI generation", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "arktype", + "json-schema", + "openapi", + "orpc", + "typesafe", + "typescript", + "validation" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/arktype" }, - "keywords": [ - "orpc", - "arktype", - "json-schema", - "openapi", - "validation", - "typescript", - "typesafe" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "@ark/schema": "*", - "arktype": "*" + "prepack": "tsc" }, "dependencies": { "@orpc/json-schema": "workspace:*" @@ -56,5 +51,9 @@ "@ark/schema": "^0.56.8", "arktype": "^2.2.9", "zod": "^4.6.7" + }, + "peerDependencies": { + "@ark/schema": "*", + "arktype": "*" } } diff --git a/packages/arktype/src/converter.test.ts b/packages/arktype/src/converter.test.ts index 7346dba396..2df669cd7d 100644 --- a/packages/arktype/src/converter.test.ts +++ b/packages/arktype/src/converter.test.ts @@ -1,6 +1,7 @@ import { type } from 'arktype' import * as z from 'zod' -import { ArkTypeToJsonSchemaConverter } from './converter' + +import { ArkTypeToJsonSchemaConverter } from './converter.ts' describe('arkTypeToJsonSchemaConverter', () => { const converter = new ArkTypeToJsonSchemaConverter() @@ -33,26 +34,47 @@ describe('arkTypeToJsonSchemaConverter', () => { 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], + [ + '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]) }) @@ -60,23 +82,35 @@ describe('arkTypeToJsonSchemaConverter', () => { describe('direction', () => { it.each([ - ['morph and default object', type({ a: 'string.numeric.parse', b: 'number = 5' }), { - type: 'object', - properties: { - a: { type: 'string', pattern: expect.any(String) }, - b: { type: 'number', default: 5 }, + [ + 'morph and default object', + type({ a: 'string.numeric.parse', b: 'number = 5' }), + { + type: 'object', + properties: { + a: { type: 'string', pattern: expect.any(String) }, + b: { type: 'number', default: 5 }, + }, + required: ['a'], }, - required: ['a'], - }, { - type: 'object', - properties: { - a: { type: 'number' }, - b: { type: 'number' }, + { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], }, - required: ['a', 'b'], - }], - ['pipe with output type', type('string').pipe(s => s.length).to('number'), { type: 'string' }, { type: 'number' }], - ['unconstrained pipe', type('string').pipe(s => s.length), { type: 'string' }, {}], + ], + [ + 'pipe with output type', + type('string') + .pipe((s) => s.length) + .to('number'), + { type: 'string' }, + { type: 'number' }, + ], + ['unconstrained pipe', type('string').pipe((s) => s.length), { type: 'string' }, {}], ] as const)('converts %s from the matching side', (_, schema, input, output) => { expect(converter.convert(schema, 'input')).toEqual([input, false]) expect(converter.convert(schema, 'output')).toEqual([output, false]) @@ -85,16 +119,22 @@ describe('arkTypeToJsonSchemaConverter', () => { 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', - }], + [ + 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]) }) diff --git a/packages/arktype/src/converter.ts b/packages/arktype/src/converter.ts index 07961cd7a2..485b09e4a7 100644 --- a/packages/arktype/src/converter.ts +++ b/packages/arktype/src/converter.ts @@ -1,9 +1,21 @@ import type { JsonSchema as ArkJsonSchema, ToJsonSchema } from '@ark/schema' -import type { AnySchema, JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' +import type { + AnySchema, + JsonSchema, + JsonSchemaConverter, + JsonSchemaConverterDirection, +} from '@orpc/json-schema' +import { + isStandardSchemaOptional, + JsonSchemaFormat, + JsonSchemaXNativeType, +} from '@orpc/json-schema' import type { Type } from 'arktype' -import { isStandardSchemaOptional, JsonSchemaFormat, JsonSchemaXNativeType } from '@orpc/json-schema' -export interface ArkTypeToJsonSchemaConverterOptions extends Omit { +export interface ArkTypeToJsonSchemaConverterOptions extends Omit< + ToJsonSchema.Options, + 'dialect' | 'target' +> { /** * Caches conversion results in a WeakMap keyed by the ArkType schema instance, * so converting the same schema again is free. @@ -24,7 +36,14 @@ export interface ArkTypeToJsonSchemaConverterOptions extends Omit } + private readonly cache: + | undefined + | { + [d in JsonSchemaConverterDirection]: WeakMap< + Type, + [jsonSchema: JsonSchema, optional: boolean] + > + } constructor({ cache, ...options }: ArkTypeToJsonSchemaConverterOptions = {}) { if (cache) { @@ -35,7 +54,9 @@ export class ArkTypeToJsonSchemaConverter implements JsonSchemaConverter { ...options, target: 'draft-2020-12', fallback: { - ...(options.fallback && typeof options.fallback !== 'function' ? options.fallback : undefined), + ...(options.fallback && typeof options.fallback !== 'function' + ? options.fallback + : undefined), default: (ctx) => { if (ctx.code === 'domain') { if (ctx.domain === 'bigint') { @@ -43,8 +64,7 @@ export class ArkTypeToJsonSchemaConverter implements JsonSchemaConverter { ;(ctx.base as any).pattern = '^-?[0-9]+$' ;(ctx.base as any)['x-native-type'] = JsonSchemaXNativeType.BigInt } - } - else if (ctx.code === 'date') { + } 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 @@ -68,7 +88,10 @@ export class ArkTypeToJsonSchemaConverter implements JsonSchemaConverter { return schema?.['~standard'].vendor === 'arktype' } - convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] { + convert( + schema: AnySchema | undefined, + direction: JsonSchemaConverterDirection, + ): [jsonSchema: JsonSchema, optional: boolean] { const arkTypeSchema = schema as Type if (this.cache) { @@ -87,7 +110,10 @@ export class ArkTypeToJsonSchemaConverter implements JsonSchemaConverter { return result } - private convertUncached(arkTypeSchema: Type, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] { + private convertUncached( + arkTypeSchema: Type, + direction: JsonSchemaConverterDirection, + ): [jsonSchema: JsonSchema, optional: boolean] { const jsonSchema = this.convertArkType(arkTypeSchema, direction) return [jsonSchema as JsonSchema, isStandardSchemaOptional(arkTypeSchema, direction)] @@ -96,11 +122,13 @@ export class ArkTypeToJsonSchemaConverter implements JsonSchemaConverter { private convertArkType(schema: Type, direction: JsonSchemaConverterDirection): ArkJsonSchema { // `toJsonSchema` describes the input side (and keeps `default` annotations), // so output conversions go through `.out` to reflect morph outputs and applied defaults. - const jsonSchema = (direction === 'output' ? schema.out : schema).toJsonSchema(this.toJsonSchemaOptions) + const jsonSchema = (direction === 'output' ? schema.out : schema).toJsonSchema( + this.toJsonSchemaOptions, + ) // Since the default oRPC format is always draft/2020-12, // `$schema` can be safely omitted here. - const { $schema, ...rest } = jsonSchema + const { $schema: _schema, ...rest } = jsonSchema return rest } } diff --git a/packages/arktype/src/index.test.ts b/packages/arktype/src/index.test.ts index ce45c67e6c..f598bec340 100644 --- a/packages/arktype/src/index.test.ts +++ b/packages/arktype/src/index.test.ts @@ -1,5 +1,5 @@ it('exports ArkTypeToJsonSchemaConverter', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ ArkTypeToJsonSchemaConverter: expect.any(Function), }) }) diff --git a/packages/arktype/src/index.ts b/packages/arktype/src/index.ts index 7c020a6175..60fa772e72 100644 --- a/packages/arktype/src/index.ts +++ b/packages/arktype/src/index.ts @@ -1,5 +1,3 @@ -export * from './converter' +export * from './converter.ts' -export type { - JsonSchema, -} from '@orpc/json-schema' +export type { JsonSchema } from '@orpc/json-schema' diff --git a/packages/arktype/tsconfig.json b/packages/arktype/tsconfig.json index 7416873ebe..9979a0715c 100644 --- a/packages/arktype/tsconfig.json +++ b/packages/arktype/tsconfig.json @@ -1,14 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../json-schema" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/bun/README.md b/packages/bun/README.md index 551a89e310..9f5e0b58bc 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -38,10 +38,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/bun/build.config.ts b/packages/bun/build.config.ts deleted file mode 100644 index 264605e07e..0000000000 --- a/packages/bun/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: ctx => verifyDist(ctx, ['bun']), - }, -}) diff --git a/packages/bun/bunfig.toml b/packages/bun/bunfig.toml index 80dfaa6c4d..365e782122 100644 --- a/packages/bun/bunfig.toml +++ b/packages/bun/bunfig.toml @@ -1,8 +1,5 @@ [test] -coverageReporter = [ - "text", - "lcov" -] +coverageReporter = ["text", "lcov"] coveragePathIgnorePatterns = [ # Exclude other packages to prevent overlap in the final merged coverage report. "../**", diff --git a/packages/bun/package.json b/packages/bun/package.json index 7ff8091637..e4b8d88fbe 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,49 +1,48 @@ { "name": "@orpc/bun", - "type": "module", "version": "2.0.0-beta.48", "description": "Bun integration for oRPC: Redis-backed pub/sub, rate limiting, and locking using Bun's built-in Redis client", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "bun", + "lock", + "orpc", + "pubsub", + "ratelimit", + "redis", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/bun" }, - "keywords": [ - "orpc", - "bun", - "redis", - "pubsub", - "ratelimit", - "lock", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b", + "prepack": "tsc -p tsconfig.build.json", "test": "bun --env-file=../../.env test", "test:coverage": "bun --env-file=../../.env test --coverage" }, diff --git a/packages/bun/src/index.test.ts b/packages/bun/src/index.test.ts index 128424dc7f..7db937bffb 100644 --- a/packages/bun/src/index.test.ts +++ b/packages/bun/src/index.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'bun:test' it('exports BunRedisRateLimiter, BunRedisPublisher, experimental_BunRedisLocker', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ BunRedisRateLimiter: expect.any(Function), BunRedisPublisher: expect.any(Function), experimental_BunRedisLocker: expect.any(Function), diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 0479105699..5ccb961a64 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -1,3 +1,3 @@ -export * from './redis-lock' -export * from './redis-publisher' -export * from './redis-ratelimit' +export * from './redis-lock.ts' +export * from './redis-publisher.ts' +export * from './redis-ratelimit.ts' diff --git a/packages/bun/src/redis-lock.test.ts b/packages/bun/src/redis-lock.test.ts index 05cfce7714..b913d32d06 100644 --- a/packages/bun/src/redis-lock.test.ts +++ b/packages/bun/src/redis-lock.test.ts @@ -1,9 +1,11 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'bun:test' + import type { Locker, LockOptions } from '@orpc/experimental-lock' import { LockTimeoutError } from '@orpc/experimental-lock' import { promiseWithResolvers, sleep } from '@orpc/shared' import { RedisClient } from 'bun' -import { afterAll, beforeAll, describe, expect, it, vi } from 'bun:test' -import { experimental_BunRedisLocker as BunRedisLocker } from './redis-lock' + +import { experimental_BunRedisLocker as BunRedisLocker } from './redis-lock.ts' const REDIS_URL = Bun.env.REDIS_URL @@ -40,11 +42,15 @@ describe.skipIf(!REDIS_URL)('bun redis locker integration', async () => { async function hold(locker: Locker, key: string, options?: LockOptions) { const started = promiseWithResolvers() const finished = promiseWithResolvers() - const done = locker.lock(key, async ({ waited }) => { - started.resolve() - await finished.promise - return waited - }, options) + const done = locker.lock( + key, + async ({ waited }) => { + started.resolve() + await finished.promise + return waited + }, + options, + ) await Promise.race([started.promise, done]) @@ -56,133 +62,181 @@ describe.skipIf(!REDIS_URL)('bun redis locker integration', async () => { } } - it('runs the callback right away when the lock is free and releases it afterwards', async () => { - const { locker } = createTestingLocker() - const fn = vi.fn(() => 'ok') - - await expect(locker.lock('key', fn)).resolves.toBe('ok') - expect(fn).toHaveBeenCalledTimes(1) - expect(fn).toHaveBeenCalledWith({ waited: false }) - - await expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again') - }, { timeout: 20_000 }) - - it('makes callers wait until the holder releases, across instances', async () => { - const key = `orpc-bun-redis-locker-${crypto.randomUUID()}` - const locker = new BunRedisLocker(redis, { ttl: 10_000 }) - const other = new BunRedisLocker(redis, { ttl: 10_000 }) - const holder = await hold(locker, key) - const fn = vi.fn(() => 'ok') - const waiter = other.lock(key, fn) - - // Commands on one client run in order, so the waiter's first attempt was rejected as well - await expect(other.lock(key, () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) - - await holder.release() - await expect(waiter).resolves.toBe('ok') - expect(fn).toHaveBeenCalledTimes(1) - expect(fn).toHaveBeenCalledWith({ waited: true }) - }, { timeout: 20_000 }) - - it('tracks locks independently per key', async () => { - const { locker } = createTestingLocker() - const holder = await hold(locker, 'alice') - const fn = vi.fn(() => 'bob') - - await expect(locker.lock('bob', fn, { timeout: 0 })).resolves.toBe('bob') - expect(fn).toHaveBeenCalledWith({ waited: false }) - - await holder.release() - }, { timeout: 20_000 }) + it( + 'runs the callback right away when the lock is free and releases it afterwards', + async () => { + const { locker } = createTestingLocker() + const fn = vi.fn(() => 'ok') + + await expect(locker.lock('key', fn)).resolves.toBe('ok') + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith({ waited: false }) + + await expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again') + }, + { timeout: 20_000 }, + ) + + it( + 'makes callers wait until the holder releases, across instances', + async () => { + const key = `orpc-bun-redis-locker-${crypto.randomUUID()}` + const locker = new BunRedisLocker(redis, { ttl: 10_000 }) + const other = new BunRedisLocker(redis, { ttl: 10_000 }) + const holder = await hold(locker, key) + const fn = vi.fn(() => 'ok') + const waiter = other.lock(key, fn) + + // Commands on one client run in order, so the waiter's first attempt was rejected as well + await expect(other.lock(key, () => 'never', { timeout: 0 })).rejects.toBeInstanceOf( + LockTimeoutError, + ) + + await holder.release() + await expect(waiter).resolves.toBe('ok') + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith({ waited: true }) + }, + { timeout: 20_000 }, + ) + + it( + 'tracks locks independently per key', + async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'alice') + const fn = vi.fn(() => 'bob') + + await expect(locker.lock('bob', fn, { timeout: 0 })).resolves.toBe('bob') + expect(fn).toHaveBeenCalledWith({ waited: false }) + + await holder.release() + }, + { timeout: 20_000 }, + ) it('shares locks with lockers created by prefix', async () => { const { locker } = createTestingLocker() const holder = await hold(locker, 'tenant:key') - await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) - await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + await expect( + locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 }), + ).rejects.toBeInstanceOf(LockTimeoutError) + await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe( + 'ok', + ) await holder.release() }) - it('releases the lock when the callback throws', async () => { - const { locker } = createTestingLocker() - - await expect(locker.lock('key', () => { - throw new Error('boom') - })).rejects.toThrow('boom') - - await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') - }, { timeout: 20_000 }) - - it('rejects with LockTimeoutError when the lock is not released in time', async () => { - const { locker } = createTestingLocker({ timeout: 200 }) - const holder = await hold(locker, 'key') - const fn = vi.fn() - const start = Date.now() - - await expect(locker.lock('key', fn)).rejects.toMatchObject({ - name: 'LockTimeoutError', - key: 'key', - message: 'Timed out waiting for the lock of key "key"', - }) - expect(Date.now() - start).toBeGreaterThanOrEqual(200) - expect(fn).not.toHaveBeenCalled() - - await holder.release() - }, { timeout: 20_000 }) - - it('hands the lock over when the ttl expires, and the expired holder cannot release it', async () => { - const { locker } = createTestingLocker({ ttl: 200 }) - const expired = await hold(locker, 'key') - const next = await hold(locker, 'key', { ttl: 10_000 }) - - await expired.release() - await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) - - await next.release() - await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') - }, { timeout: 20_000 }) - - it('stops waiting when the signal is aborted', async () => { - const { locker } = createTestingLocker() - const holder = await hold(locker, 'key') - const controller = new AbortController() - const fn = vi.fn() - const waiter = locker.lock('key', fn, { signal: controller.signal }) - - controller.abort(new Error('aborted')) - - await expect(waiter).rejects.toThrow('aborted') - await expect(locker.lock('key', fn, { signal: controller.signal })).rejects.toThrow('aborted') - expect(fn).not.toHaveBeenCalled() - - await holder.release() - }, { timeout: 20_000 }) - - it('never runs callbacks for the same key concurrently', async () => { - const { locker } = createTestingLocker() - let active = 0 - let maxActive = 0 - let count = 0 - - await Promise.all(Array.from({ length: 5 }, () => locker.lock('key', async () => { - active++ - maxActive = Math.max(maxActive, active) - const current = count - await sleep(5) - count = current + 1 - active-- - }))) - - expect(maxActive).toBe(1) - expect(count).toBe(5) - }, { timeout: 20_000 }) + it( + 'releases the lock when the callback throws', + async () => { + const { locker } = createTestingLocker() + + await expect( + locker.lock('key', () => { + throw new Error('boom') + }), + ).rejects.toThrow('boom') + + await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + }, + { timeout: 20_000 }, + ) + + it( + 'rejects with LockTimeoutError when the lock is not released in time', + async () => { + const { locker } = createTestingLocker({ timeout: 200 }) + const holder = await hold(locker, 'key') + const fn = vi.fn() + const start = Date.now() + + await expect(locker.lock('key', fn)).rejects.toMatchObject({ + name: 'LockTimeoutError', + key: 'key', + message: 'Timed out waiting for the lock of key "key"', + }) + expect(Date.now() - start).toBeGreaterThanOrEqual(200) + expect(fn).not.toHaveBeenCalled() + + await holder.release() + }, + { timeout: 20_000 }, + ) + + it( + 'hands the lock over when the ttl expires, and the expired holder cannot release it', + async () => { + const { locker } = createTestingLocker({ ttl: 200 }) + const expired = await hold(locker, 'key') + const next = await hold(locker, 'key', { ttl: 10_000 }) + + await expired.release() + await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf( + LockTimeoutError, + ) + + await next.release() + await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + }, + { timeout: 20_000 }, + ) + + it( + 'stops waiting when the signal is aborted', + async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'key') + const controller = new AbortController() + const fn = vi.fn() + const waiter = locker.lock('key', fn, { signal: controller.signal }) + + controller.abort(new Error('aborted')) + + await expect(waiter).rejects.toThrow('aborted') + await expect(locker.lock('key', fn, { signal: controller.signal })).rejects.toThrow('aborted') + expect(fn).not.toHaveBeenCalled() + + await holder.release() + }, + { timeout: 20_000 }, + ) + + it( + 'never runs callbacks for the same key concurrently', + async () => { + const { locker } = createTestingLocker() + let active = 0 + let maxActive = 0 + let count = 0 + + await Promise.all( + Array.from({ length: 5 }, () => + locker.lock('key', async () => { + active++ + maxActive = Math.max(maxActive, active) + const current = count + await sleep(5) + count = current + 1 + active-- + }), + ), + ) + + expect(maxActive).toBe(1) + expect(count).toBe(5) + }, + { timeout: 20_000 }, + ) }) it('preserves the callback outcome when the release fails', async () => { const redis = { - send: vi.fn((command: string) => command === 'SET' ? Promise.resolve('OK') : Promise.reject(new Error('connection lost'))), + send: vi.fn((command: string) => + command === 'SET' ? Promise.resolve('OK') : Promise.reject(new Error('connection lost')), + ), } const locker = new BunRedisLocker(redis as any, { ttl: 1000 }) const error = new Error('fn failed') diff --git a/packages/bun/src/redis-lock.ts b/packages/bun/src/redis-lock.ts index 1457c0b020..82445a471e 100644 --- a/packages/bun/src/redis-lock.ts +++ b/packages/bun/src/redis-lock.ts @@ -1,6 +1,6 @@ import type { BaseRedisLockerOptions } from '@orpc/experimental-lock/base-redis' -import type { RedisClient } from 'bun' import { BaseRedisLocker } from '@orpc/experimental-lock/base-redis' +import type { RedisClient } from 'bun' /** * Locker adapter for Bun's built-in Redis client. Acquires locks with `SET NX PX` @@ -22,8 +22,10 @@ export class experimental_BunRedisLocker extends BaseRedisLocker { * @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters} */ prefix(prefix: string): experimental_BunRedisLocker { - // eslint-disable-next-line new-cap - return new experimental_BunRedisLocker(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + return new experimental_BunRedisLocker(this.redis, { + ...this.options, + prefix: `${this.keyPrefix}${prefix}`, + }) } protected async acquire(key: string, token: string, ttl: number): Promise { diff --git a/packages/bun/src/redis-publisher.test.ts b/packages/bun/src/redis-publisher.test.ts index 2cfce44373..2fbd1a5221 100644 --- a/packages/bun/src/redis-publisher.test.ts +++ b/packages/bun/src/redis-publisher.test.ts @@ -1,11 +1,13 @@ -import type { BunRedisPublisherOptions } from './redis-publisher' +import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from 'bun:test' + import { RPCJsonSerializer } from '@orpc/client' import { getOrBind, promiseWithResolvers, sleep } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@standard-server/core' import { RedisClient } from 'bun' -import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from 'bun:test' -import { waitFor } from '../tests/__shared__/utils' -import { BunRedisPublisher } from './redis-publisher' + +import { waitFor } from '../tests/__shared__/utils.ts' +import type { BunRedisPublisherOptions } from './redis-publisher.ts' +import { BunRedisPublisher } from './redis-publisher.ts' const REDIS_URL = process.env.REDIS_URL @@ -30,13 +32,10 @@ describe.skipIf(!REDIS_URL)('bun redis publisher', () => { options: BunRedisPublisherOptions = {}, { useRedis = redis }: { useRedis?: RedisClient } = {}, ) { - const publisher = new BunRedisPublisher>( - useRedis, - { - prefix: `redis:${crypto.randomUUID()}:`, - ...options, - }, - ) + const publisher = new BunRedisPublisher>(useRedis, { + prefix: `redis:${crypto.randomUUID()}:`, + ...options, + }) ;(publisher as any).xTrimExactness = '=' // for easier testing @@ -45,196 +44,227 @@ describe.skipIf(!REDIS_URL)('bun redis publisher', () => { return publisher } - it('delivers live events and meta (without resume and without prefix)', async () => { - const publisher = createTestingPublisher({ prefix: undefined }) - const liveEvent = `${crypto.randomUUID()}live` - const ignoredEvent = `${crypto.randomUUID()}ignored` - const listener = vi.fn() + it( + 'delivers live events and meta (without resume and without prefix)', + async () => { + const publisher = createTestingPublisher({ prefix: undefined }) + const liveEvent = `${crypto.randomUUID()}live` + const ignoredEvent = `${crypto.randomUUID()}ignored` + const listener = vi.fn() - const unsubscribe = await publisher.subscribe(liveEvent, listener) + const unsubscribe = await publisher.subscribe(liveEvent, listener) - await publisher.publish(ignoredEvent, { order: 99 }) - await publisher.publish(liveEvent, { order: 1 }) - await publisher.publish(liveEvent, withEventMeta({ order: 2 }, { id: '__id__', comments: ['__comment__'] })) + await publisher.publish(ignoredEvent, { order: 99 }) + await publisher.publish(liveEvent, { order: 1 }) + await publisher.publish( + liveEvent, + withEventMeta({ order: 2 }, { id: '__id__', comments: ['__comment__'] }), + ) - await waitFor(() => { - expect(listener).toHaveBeenCalledTimes(2) - }) + await waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + }) - const payload0 = listener.mock.calls[0]![0] - expect(payload0).toEqual({ order: 1 }) - expect(getEventMeta(payload0)).toEqual(undefined) + const payload0 = listener.mock.calls[0]![0] + expect(payload0).toEqual({ order: 1 }) + expect(getEventMeta(payload0)).toEqual(undefined) - const payload1 = listener.mock.calls[1]![0] - expect(payload1).toEqual({ order: 2 }) - expect(getEventMeta(payload1)).toEqual({ id: '__id__', comments: ['__comment__'] }) + const payload1 = listener.mock.calls[1]![0] + expect(payload1).toEqual({ order: 2 }) + expect(getEventMeta(payload1)).toEqual({ id: '__id__', comments: ['__comment__'] }) - await unsubscribe() + await unsubscribe() - const resumeAttempt = vi.fn() - const unsubscribeResumeAttempt = await publisher.subscribe(liveEvent, resumeAttempt, { lastEventId: '0' }) + const resumeAttempt = vi.fn() + const unsubscribeResumeAttempt = await publisher.subscribe(liveEvent, resumeAttempt, { + lastEventId: '0', + }) - await sleep(300) + await sleep(300) - expect(resumeAttempt).not.toHaveBeenCalled() + expect(resumeAttempt).not.toHaveBeenCalled() - await unsubscribeResumeAttempt() - }, { timeout: 20_000 }) + await unsubscribeResumeAttempt() + }, + { timeout: 20_000 }, + ) - it('shares the subscriber with publishers created by prefix', async () => { - const publisher = createTestingPublisher() - const tenant = publisher.prefix('tenant:') - const event = crypto.randomUUID() - const listener = vi.fn() - const onError = vi.fn() + it( + 'shares the subscriber with publishers created by prefix', + async () => { + const publisher = createTestingPublisher() + const tenant = publisher.prefix('tenant:') + const event = crypto.randomUUID() + const listener = vi.fn() + const onError = vi.fn() - const unsubscribe = await tenant.subscribe(event, listener, { onError }) + const unsubscribe = await tenant.subscribe(event, listener, { onError }) - await publisher.publish(`tenant:${event}`, { order: 1 }) - await publisher.prefix('other:').publish(event, { order: 2 }) - await tenant.publish(event, { order: 3 }) + await publisher.publish(`tenant:${event}`, { order: 1 }) + await publisher.prefix('other:').publish(event, { order: 2 }) + await tenant.publish(event, { order: 3 }) - await waitFor(() => { - expect(listener).toHaveBeenCalledTimes(2) - }) - expect(listener).toHaveBeenNthCalledWith(1, { order: 1 }) - expect(listener).toHaveBeenNthCalledWith(2, { order: 3 }) + await waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + }) + expect(listener).toHaveBeenNthCalledWith(1, { order: 1 }) + expect(listener).toHaveBeenNthCalledWith(2, { order: 3 }) - const subscriber: RedisClient = await (publisher as any).getSubscriber() - expect(await (tenant as any).getSubscriber()).toBe(subscriber) + const subscriber: RedisClient = await (publisher as any).getSubscriber() + expect(await (tenant as any).getSubscriber()).toBe(subscriber) - const error = new Error('boom') - subscriber.onclose!.call(subscriber, error) - expect(onError).toHaveBeenCalledWith(error) + const error = new Error('boom') + subscriber.onclose!.call(subscriber, error) + expect(onError).toHaveBeenCalledWith(error) - await unsubscribe() - }, { timeout: 20_000 }) + await unsubscribe() + }, + { timeout: 20_000 }, + ) - it('resumes missed events in order and preserves event metadata while rewriting ids', async () => { - const publisher = createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - }) - const event = 'orders' - const liveListener = vi.fn() + it( + 'resumes missed events in order and preserves event metadata while rewriting ids', + async () => { + const publisher = createTestingPublisher({ + resume: { enabled: true, ttl: 10_000 }, + }) + const event = 'orders' + const liveListener = vi.fn() - const unsubscribeLive = await publisher.subscribe(event, liveListener) + const unsubscribeLive = await publisher.subscribe(event, liveListener) - const firstPayload = { order: 1 } - const secondPayload = withEventMeta({ order: 2 }, { id: 'client-id', comments: ['audit-log'] }) - const thirdPayload = { order: 3 } + const firstPayload = { order: 1 } + const secondPayload = withEventMeta( + { order: 2 }, + { id: 'client-id', comments: ['audit-log'] }, + ) + const thirdPayload = { order: 3 } - await publisher.publish(event, firstPayload) - await publisher.publish(event, secondPayload) - await publisher.publish(event, thirdPayload) + await publisher.publish(event, firstPayload) + await publisher.publish(event, secondPayload) + await publisher.publish(event, thirdPayload) - await waitFor(() => { - expect(liveListener).toHaveBeenCalledTimes(3) - }) + await waitFor(() => { + expect(liveListener).toHaveBeenCalledTimes(3) + }) - const firstDelivered = liveListener.mock.calls[0]![0] - const secondDelivered = liveListener.mock.calls[1]![0] - const thirdDelivered = liveListener.mock.calls[2]![0] + const firstDelivered = liveListener.mock.calls[0]![0] + const secondDelivered = liveListener.mock.calls[1]![0] + const thirdDelivered = liveListener.mock.calls[2]![0] - expect(firstDelivered).toEqual(firstPayload) - expect(getEventMeta(firstDelivered)?.id).toEqual(expect.any(String)) + expect(firstDelivered).toEqual(firstPayload) + expect(getEventMeta(firstDelivered)?.id).toEqual(expect.any(String)) - expect(secondDelivered).toEqual(secondPayload) - expect(secondDelivered).not.toBe(secondPayload) - expect(getEventMeta(secondDelivered)?.id).toEqual(expect.any(String)) - expect(getEventMeta(secondDelivered)?.id).not.toEqual('client-id') - expect(getEventMeta(secondDelivered)?.comments).toEqual(['audit-log']) + expect(secondDelivered).toEqual(secondPayload) + expect(secondDelivered).not.toBe(secondPayload) + expect(getEventMeta(secondDelivered)?.id).toEqual(expect.any(String)) + expect(getEventMeta(secondDelivered)?.id).not.toEqual('client-id') + expect(getEventMeta(secondDelivered)?.comments).toEqual(['audit-log']) - await unsubscribeLive() + await unsubscribeLive() - const resumed = vi.fn() - const unsubscribeResume = await publisher.subscribe(event, resumed, { - lastEventId: getEventMeta(secondDelivered)?.id, - }) + const resumed = vi.fn() + const unsubscribeResume = await publisher.subscribe(event, resumed, { + lastEventId: getEventMeta(secondDelivered)?.id, + }) - await waitFor(() => { - expect(resumed).toHaveBeenCalledTimes(1) - }) + await waitFor(() => { + expect(resumed).toHaveBeenCalledTimes(1) + }) - const resumedPayload = resumed.mock.calls[0]![0] + const resumedPayload = resumed.mock.calls[0]![0] - expect(resumedPayload).toEqual(thirdDelivered) - expect(getEventMeta(resumedPayload)?.id).toEqual(getEventMeta(thirdDelivered)?.id) + expect(resumedPayload).toEqual(thirdDelivered) + expect(getEventMeta(resumedPayload)?.id).toEqual(getEventMeta(thirdDelivered)?.id) - await unsubscribeResume() - }, { timeout: 20_000 }) + await unsubscribeResume() + }, + { timeout: 20_000 }, + ) - it('treats an empty resume backlog as a clean starting point and then continues with live messages', async () => { - const publisher = createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - }) - const event = 'empty-backlog' - const listener = vi.fn() + it( + 'treats an empty resume backlog as a clean starting point and then continues with live messages', + async () => { + const publisher = createTestingPublisher({ + resume: { enabled: true, ttl: 10_000 }, + }) + const event = 'empty-backlog' + const listener = vi.fn() - const unsubscribe = await publisher.subscribe(event, listener, { lastEventId: '0' }) + const unsubscribe = await publisher.subscribe(event, listener, { lastEventId: '0' }) - await sleep(300) + await sleep(300) - expect(listener).not.toHaveBeenCalled() + expect(listener).not.toHaveBeenCalled() - await publisher.publish(event, { order: 1 }) + await publisher.publish(event, { order: 1 }) - await waitFor(() => { - expect(listener).toHaveBeenCalledTimes(1) - }) + await waitFor(() => { + expect(listener).toHaveBeenCalledTimes(1) + }) - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ order: 1 })) + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ order: 1 })) - await unsubscribe() - }, { timeout: 20_000 }) - - it('deduplicates events that race between resume and live delivery during reconnect', async () => { - const { resolve, promise } = promiseWithResolvers() - const delayedRedis = new Proxy(new RedisClient(REDIS_URL), { - get(target, p) { - if (p === 'send') { - const originalSend = getOrBind(target, p) - return async (command: string, args: any[]) => { - if (command === 'XREAD') { - await promise + await unsubscribe() + }, + { timeout: 20_000 }, + ) + + it( + 'deduplicates events that race between resume and live delivery during reconnect', + async () => { + const { resolve, promise } = promiseWithResolvers() + const delayedRedis = new Proxy(new RedisClient(REDIS_URL), { + get(target, p) { + if (p === 'send') { + const originalSend = getOrBind(target, p) + return async (command: string, args: any[]) => { + if (command === 'XREAD') { + await promise + } + + return originalSend(command, args) } - - return originalSend(command, args) } - } - return getOrBind(target, p) - }, - }) + return getOrBind(target, p) + }, + }) - onTestFinished(() => { - delayedRedis.close() - }) + onTestFinished(() => { + delayedRedis.close() + }) - const publisher = createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - }, { useRedis: delayedRedis }) - const event = 'timeline' + const publisher = createTestingPublisher( + { + resume: { enabled: true, ttl: 10_000 }, + }, + { useRedis: delayedRedis }, + ) + const event = 'timeline' - await publisher.publish(event, { order: 1 }) - await publisher.publish(event, { order: 2 }) + await publisher.publish(event, { order: 1 }) + await publisher.publish(event, { order: 2 }) - const listener = vi.fn() - const [unsubscribe] = await Promise.all([ - publisher.subscribe(event, listener, { lastEventId: '0' }), - sleep(1000) - .then(() => publisher.publish(event, { order: 3 })) - .then(() => publisher.publish(event, { order: 4 })) - .then(() => resolve()), - ]) + const listener = vi.fn() + const [unsubscribe] = await Promise.all([ + publisher.subscribe(event, listener, { lastEventId: '0' }), + sleep(1000) + .then(() => publisher.publish(event, { order: 3 })) + .then(() => publisher.publish(event, { order: 4 })) + .then(() => resolve()), + ]) - await waitFor(() => { - expect(listener).toHaveBeenCalledTimes(4) - }) + await waitFor(() => { + expect(listener).toHaveBeenCalledTimes(4) + }) - expect(listener.mock.calls.map(call => call[0].order)).toEqual([1, 2, 3, 4]) + expect(listener.mock.calls.map((call) => call[0].order)).toEqual([1, 2, 3, 4]) - await unsubscribe() - }, { timeout: 20_000 }) + await unsubscribe() + }, + { timeout: 20_000 }, + ) it('keeps live delivery in stream order under concurrent publishers so resume skips nothing', async () => { const prefix = `concurrent:${crypto.randomUUID()}:` @@ -242,273 +272,317 @@ describe.skipIf(!REDIS_URL)('bun redis publisher', () => { // Separate connections, like publishers in different processes. const clients = Array.from({ length: 4 }, () => new RedisClient(REDIS_URL)) onTestFinished(() => { - clients.forEach(client => client.close()) + clients.forEach((client) => client.close()) }) - const publishers = clients.map(client => createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - prefix, - }, { useRedis: client })) + const publishers = clients.map((client) => + createTestingPublisher( + { + resume: { enabled: true, ttl: 10_000 }, + prefix, + }, + { useRedis: client }, + ), + ) const listener = vi.fn() const unsubscribe = await publishers[0]!.subscribe(event, listener) - await Promise.all(Array.from({ length: 500 }, (_, order) => publishers[order % publishers.length]!.publish(event, { order }))) + await Promise.all( + Array.from({ length: 500 }, (_, order) => + publishers[order % publishers.length]!.publish(event, { order }), + ), + ) await waitFor(() => { expect(listener).toHaveBeenCalledTimes(500) }) - const streamIds = (await redis.send('XRANGE', [`${prefix}${event}`, '-', '+'])).map(([id]: [string]) => id) + const streamIds = (await redis.send('XRANGE', [`${prefix}${event}`, '-', '+'])).map( + ([id]: [string]) => id, + ) expect(listener.mock.calls.map(([payload]) => getEventMeta(payload)?.id)).toEqual(streamIds) await unsubscribe() }) - it('trims stale resume history on the next publish and lets Redis expire the stream key', async () => { - const prefix = `retention:${crypto.randomUUID()}:` - const event = 'orders' - const key = `${prefix}${event}` - const publisher = createTestingPublisher({ - resume: { enabled: true, ttl: 1_000 }, - prefix, - }) - - await Promise.all([ - publisher.publish(event, { order: 1 }), - publisher.publish(event, { order: 2 }), - publisher.publish(event, { order: 3 }), - ]) - - const beforeCleanup = await redis.send('XRANGE', [key, '-', '+']) - expect(beforeCleanup.length).toBe(3) - - await sleep(1100) - await publisher.publish(event, { order: 4 }) - - const afterCleanup = await redis.send('XRANGE', [key, '-', '+']) - expect(afterCleanup.length).toBe(1) - - const ttl = await redis.ttl(key) - expect(ttl).toBeGreaterThan(0) - expect(ttl).toBeLessThanOrEqual(2) - - await sleep(2500) - - expect(await redis.exists(key)).toBe(false) - }, { timeout: 20_000 }) - - it('supports custom serializers and custom prefixes for cross-process payloads', async () => { - class Person { - constructor( - public name: string, - public date: Date, - ) { + it( + 'trims stale resume history on the next publish and lets Redis expire the stream key', + async () => { + const prefix = `retention:${crypto.randomUUID()}:` + const event = 'orders' + const key = `${prefix}${event}` + const publisher = createTestingPublisher({ + resume: { enabled: true, ttl: 1_000 }, + prefix, + }) + + await Promise.all([ + publisher.publish(event, { order: 1 }), + publisher.publish(event, { order: 2 }), + publisher.publish(event, { order: 3 }), + ]) + + const beforeCleanup = await redis.send('XRANGE', [key, '-', '+']) + expect(beforeCleanup.length).toBe(3) + + await sleep(1100) + await publisher.publish(event, { order: 4 }) + + const afterCleanup = await redis.send('XRANGE', [key, '-', '+']) + expect(afterCleanup.length).toBe(1) + + const ttl = await redis.ttl(key) + expect(ttl).toBeGreaterThan(0) + expect(ttl).toBeLessThanOrEqual(2) + + await sleep(2500) + + expect(await redis.exists(key)).toBe(false) + }, + { timeout: 20_000 }, + ) + + it( + 'supports custom serializers and custom prefixes for cross-process payloads', + async () => { + class Person { + constructor( + public name: string, + public date: Date, + ) {} } - } - - const serializer = new RPCJsonSerializer({ - handlers: { - person: { - condition: p => p instanceof Person, - serialize: p => ({ name: p.name, date: p.date }), - deserialize: (p: any) => new Person(p.name, p.date), - }, - }, - }) - - const prefix = `custom:${crypto.randomUUID()}:` - const event = 'profile-updated' - const publisher = createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - serializer, - prefix, - }) - - const listener = vi.fn() - const unsubscribe = await publisher.subscribe(event, listener) - - const payload = { - order: 1, - nested: { - value: 'test', - array: [1, 2, 3], - }, - date: new Date('2024-01-01'), - person: new Person('John Doe', new Date('2023-01-01')), - } - - await publisher.publish(event, payload) - - await waitFor(() => { - expect(listener).toHaveBeenCalledTimes(1) - }) - - const received = listener.mock.calls[0]![0] - - expect(received.order).toBe(1) - expect(received.nested.value).toBe('test') - expect(received.nested.array).toEqual([1, 2, 3]) - expect(received.date).toEqual(new Date('2024-01-01')) - expect(received.person).toEqual(new Person('John Doe', new Date('2023-01-01'))) - - const keys = await redis.keys(`${prefix}*`) - expect(keys).toContain(`${prefix}${event}`) - - await unsubscribe() - }, { timeout: 20_000 }) - - it('reports resume errors while subscribing', async () => { - const publisher = createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - }) - const event = 'resume-errors' - const listener = vi.fn() - const onError = vi.fn() - - await expect(publisher.subscribe(event, listener, { - lastEventId: 'invalid-id-format', - onError, - })).rejects.toThrow() - - await sleep(1000) - await publisher.publish(event, { order: 1 }) - await sleep(300) - - expect(onError).not.toHaveBeenCalled() - expect(listener).not.toHaveBeenCalled() - }, { timeout: 20_000, retry: 5 }) // not sure why but bun.redis can slow on unsubscribe, so we need retry sometimes - - it('fan-outs malformed pubsub payload errors to every registered subscriber', async () => { - const prefix = `invalid:${crypto.randomUUID()}:` - const event = 'alerts' - const publisher = createTestingPublisher({ prefix }) - const listener1 = vi.fn() - const listener2 = vi.fn() - const onError1 = vi.fn() - const onError2 = vi.fn() - - const unsubscribe1 = await publisher.subscribe(event, listener1, { onError: onError1 }) - const unsubscribe2 = await publisher.subscribe(event, listener2, { onError: onError2 }) - - await redis.publish(`${prefix}${event}`, 'invalid') - - await waitFor(() => { - expect(onError1).toHaveBeenCalledTimes(1) - expect(onError2).toHaveBeenCalledTimes(1) - }) - - expect(listener1).not.toHaveBeenCalled() - expect(listener2).not.toHaveBeenCalled() - - await unsubscribe1() - await unsubscribe2() - }, { timeout: 20_000 }) - - it('supports duplicate listener registration and idempotent unsubscribe handles', async () => { - const prefix = `reuse:${crypto.randomUUID()}:` - const event = 'status' - const publisher = createTestingPublisher({ prefix }) - const listener = vi.fn() - const onError = vi.fn() - - const unsubscribe1 = await publisher.subscribe(event, listener, { onError }) - const unsubscribe2 = await publisher.subscribe(event, listener, { onError }) - - await Promise.all([ - publisher.publish(event, { order: 1 }), - redis.publish(`${prefix}${event}`, 'invalid'), - ]) - - await waitFor(() => { - expect(listener).toHaveBeenCalledTimes(2) - expect(onError).toHaveBeenCalledTimes(2) - }) - await Promise.all([unsubscribe1(), unsubscribe1(), unsubscribe1()]) + const serializer = new RPCJsonSerializer({ + handlers: { + person: { + condition: (p) => p instanceof Person, + serialize: (p) => ({ name: p.name, date: p.date }), + deserialize: (p: any) => new Person(p.name, p.date), + }, + }, + }) + + const prefix = `custom:${crypto.randomUUID()}:` + const event = 'profile-updated' + const publisher = createTestingPublisher({ + resume: { enabled: true, ttl: 10_000 }, + serializer, + prefix, + }) + + const listener = vi.fn() + const unsubscribe = await publisher.subscribe(event, listener) + + const payload = { + order: 1, + nested: { + value: 'test', + array: [1, 2, 3], + }, + date: new Date('2024-01-01'), + person: new Person('John Doe', new Date('2023-01-01')), + } - await Promise.all([ - publisher.publish(event, { order: 2 }), - redis.publish(`${prefix}${event}`, 'invalid'), - ]) + await publisher.publish(event, payload) - await waitFor(() => { - expect(listener).toHaveBeenCalledTimes(3) - expect(onError).toHaveBeenCalledTimes(3) - }) + await waitFor(() => { + expect(listener).toHaveBeenCalledTimes(1) + }) - await unsubscribe2() - }, { timeout: 20_000 }) + const received = listener.mock.calls[0]![0] - it('reports a lost subscriber connection to the onError of active subscriptions only', async () => { - const userSubscriber = await redis.duplicate() - const userOnclose = vi.fn() - userSubscriber.onclose = userOnclose - onTestFinished(() => { - userSubscriber.close() - }) - const publishers = [ - createTestingPublisher(), - createTestingPublisher({ subscriber: userSubscriber }), - ] + expect(received.order).toBe(1) + expect(received.nested.value).toBe('test') + expect(received.nested.array).toEqual([1, 2, 3]) + expect(received.date).toEqual(new Date('2024-01-01')) + expect(received.person).toEqual(new Person('John Doe', new Date('2023-01-01'))) - for (const publisher of publishers) { - const onError = vi.fn() - const error = new Error('boom') - - const unsubscribe = await publisher.subscribe('connection-errors', () => {}, { onError }) - const subscriber: RedisClient = await (publisher as any).getSubscriber() - - subscriber.onconnect!.call(subscriber) - subscriber.onclose!.call(subscriber, error) - - expect(onError).toHaveBeenCalledTimes(2) - expect(onError).toHaveBeenNthCalledWith(1, expect.any(Error)) - expect(onError).toHaveBeenNthCalledWith(2, error) + const keys = await redis.keys(`${prefix}*`) + expect(keys).toContain(`${prefix}${event}`) await unsubscribe() + }, + { timeout: 20_000 }, + ) + + it( + 'reports resume errors while subscribing', + async () => { + const publisher = createTestingPublisher({ + resume: { enabled: true, ttl: 10_000 }, + }) + const event = 'resume-errors' + const listener = vi.fn() + const onError = vi.fn() - subscriber.onconnect!.call(subscriber) - subscriber.onclose!.call(subscriber, error) + await expect( + publisher.subscribe(event, listener, { + lastEventId: 'invalid-id-format', + onError, + }), + ).rejects.toThrow() + + await sleep(1000) + await publisher.publish(event, { order: 1 }) + await sleep(300) + + expect(onError).not.toHaveBeenCalled() + expect(listener).not.toHaveBeenCalled() + }, + { timeout: 20_000, retry: 5 }, + ) // not sure why but bun.redis can slow on unsubscribe, so we need retry sometimes + + it( + 'fan-outs malformed pubsub payload errors to every registered subscriber', + async () => { + const prefix = `invalid:${crypto.randomUUID()}:` + const event = 'alerts' + const publisher = createTestingPublisher({ prefix }) + const listener1 = vi.fn() + const listener2 = vi.fn() + const onError1 = vi.fn() + const onError2 = vi.fn() + + const unsubscribe1 = await publisher.subscribe(event, listener1, { onError: onError1 }) + const unsubscribe2 = await publisher.subscribe(event, listener2, { onError: onError2 }) + + await redis.publish(`${prefix}${event}`, 'invalid') + + await waitFor(() => { + expect(onError1).toHaveBeenCalledTimes(1) + expect(onError2).toHaveBeenCalledTimes(1) + }) + + expect(listener1).not.toHaveBeenCalled() + expect(listener2).not.toHaveBeenCalled() + + await unsubscribe1() + await unsubscribe2() + }, + { timeout: 20_000 }, + ) + + it( + 'supports duplicate listener registration and idempotent unsubscribe handles', + async () => { + const prefix = `reuse:${crypto.randomUUID()}:` + const event = 'status' + const publisher = createTestingPublisher({ prefix }) + const listener = vi.fn() + const onError = vi.fn() - expect(onError).toHaveBeenCalledTimes(2) - } + const unsubscribe1 = await publisher.subscribe(event, listener, { onError }) + const unsubscribe2 = await publisher.subscribe(event, listener, { onError }) + + await Promise.all([ + publisher.publish(event, { order: 1 }), + redis.publish(`${prefix}${event}`, 'invalid'), + ]) + + await waitFor(() => { + expect(listener).toHaveBeenCalledTimes(2) + expect(onError).toHaveBeenCalledTimes(2) + }) + + await Promise.all([unsubscribe1(), unsubscribe1(), unsubscribe1()]) + + await Promise.all([ + publisher.publish(event, { order: 2 }), + redis.publish(`${prefix}${event}`, 'invalid'), + ]) + + await waitFor(() => { + expect(listener).toHaveBeenCalledTimes(3) + expect(onError).toHaveBeenCalledTimes(3) + }) + + await unsubscribe2() + }, + { timeout: 20_000 }, + ) + + it( + 'reports a lost subscriber connection to the onError of active subscriptions only', + async () => { + const userSubscriber = await redis.duplicate() + const userOnclose = vi.fn() + // oxlint-disable-next-line unicorn/prefer-add-event-listener + userSubscriber.onclose = userOnclose + onTestFinished(() => { + userSubscriber.close() + }) + const publishers = [ + createTestingPublisher(), + createTestingPublisher({ subscriber: userSubscriber }), + ] + + for (const publisher of publishers) { + const onError = vi.fn() + const error = new Error('boom') + + const unsubscribe = await publisher.subscribe('connection-errors', () => {}, { onError }) + const subscriber: RedisClient = await (publisher as any).getSubscriber() + + subscriber.onconnect!.call(subscriber) + subscriber.onclose!.call(subscriber, error) + + expect(onError).toHaveBeenCalledTimes(2) + expect(onError).toHaveBeenNthCalledWith(1, expect.any(Error)) + expect(onError).toHaveBeenNthCalledWith(2, error) + + await unsubscribe() + + subscriber.onconnect!.call(subscriber) + subscriber.onclose!.call(subscriber, error) + + expect(onError).toHaveBeenCalledTimes(2) + } - expect(userOnclose).toHaveBeenCalledTimes(2) - }, { timeout: 20_000 }) + expect(userOnclose).toHaveBeenCalledTimes(2) + }, + { timeout: 20_000 }, + ) it.each([ // Bun reconnects without restoring subscriptions, so a new subscription must restore delivery. - ['dropped', async (subscriber: RedisClient) => { - await redis.send('CLIENT', ['KILL', 'ID', String(await subscriber.send('CLIENT', ['ID']))]) - }], + [ + 'dropped', + async (subscriber: RedisClient) => { + await redis.send('CLIENT', ['KILL', 'ID', String(await subscriber.send('CLIENT', ['ID']))]) + }, + ], // Bun also closes the client like this once it gives up reconnecting. ['closed', (subscriber: RedisClient) => subscriber.close()], - ] as const)('reports a %s subscriber connection and delivers again after subscribing again', async (_, loseConnection) => { - const publisher = createTestingPublisher() - const event = 'alerts' - const onError = vi.fn() + ] as const)( + 'reports a %s subscriber connection and delivers again after subscribing again', + async (_, loseConnection) => { + const publisher = createTestingPublisher() + const event = 'alerts' + const onError = vi.fn() - const unsubscribe = await publisher.subscribe(event, () => {}, { onError }) + const unsubscribe = await publisher.subscribe(event, () => {}, { onError }) - await loseConnection(await (publisher as any).getSubscriber()) + await loseConnection(await (publisher as any).getSubscriber()) - await waitFor(() => { - expect(onError).toHaveBeenCalledWith(expect.any(Error)) - }) + await waitFor(() => { + expect(onError).toHaveBeenCalledWith(expect.any(Error)) + }) - await unsubscribe() // must not reject, even on a closed connection + await unsubscribe() // must not reject, even on a closed connection - const listener = vi.fn() - const unsubscribeAgain = await publisher.subscribe(event, listener) + const listener = vi.fn() + const unsubscribeAgain = await publisher.subscribe(event, listener) - await publisher.publish(event, { order: 1 }) + await publisher.publish(event, { order: 1 }) - await waitFor(() => { - expect(listener).toHaveBeenCalledTimes(1) - }) + await waitFor(() => { + expect(listener).toHaveBeenCalledTimes(1) + }) - expect(listener).toHaveBeenCalledWith({ order: 1 }) + expect(listener).toHaveBeenCalledWith({ order: 1 }) - await unsubscribeAgain() - }, { timeout: 20_000 }) + await unsubscribeAgain() + }, + { timeout: 20_000 }, + ) }) diff --git a/packages/bun/src/redis-publisher.ts b/packages/bun/src/redis-publisher.ts index c624031915..fbc933baf8 100644 --- a/packages/bun/src/redis-publisher.ts +++ b/packages/bun/src/redis-publisher.ts @@ -1,8 +1,8 @@ import type { BaseRedisPublisherOptions, RedisStreamEntry } from '@orpc/publisher/base-redis' -import type { Promisable, ThrowableError } from '@orpc/shared' -import type { RedisClient } from 'bun' import { BaseRedisPublisher } from '@orpc/publisher/base-redis' +import type { Promisable, ThrowableError } from '@orpc/shared' import { once } from '@orpc/shared' +import type { RedisClient } from 'bun' export interface BunRedisPublisherOptions extends BaseRedisPublisherOptions { /** @@ -49,7 +49,10 @@ export class BunRedisPublisher> extends BaseRed * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} */ prefix(prefix: string): BunRedisPublisher { - const publisher = new BunRedisPublisher(this.redis, { ...this.options, prefix: `${this.eventPrefix}${prefix}` }) + const publisher = new BunRedisPublisher(this.redis, { + ...this.options, + prefix: `${this.eventPrefix}${prefix}`, + }) // Share the lazily created subscriber, which reports its errors to `onErrors` of this publisher. publisher.getSubscriber = this.getSubscriber @@ -91,8 +94,7 @@ export class BunRedisPublisher> extends BaseRed try { await subscriber.unsubscribe(channel, listener) - } - catch (error) { + } catch (error) { if (subscriber.connected) { throw error } @@ -114,13 +116,16 @@ export class BunRedisPublisher> extends BaseRed private watchSubscriber(subscriber: RedisClient): void { const { onconnect, onclose } = subscriber // Snapshot, since a callback may unsubscribe itself mid-dispatch. - const report = (error: ThrowableError) => this.onErrors.slice().forEach(onError => onError(error)) + const report = (error: ThrowableError) => + this.onErrors.slice().forEach((onError) => onError(error)) + // oxlint-disable-next-line unicorn/prefer-add-event-listener subscriber.onconnect = function () { onconnect?.call(this) report(new Error('Redis subscriber reconnected without its subscriptions.')) } + // oxlint-disable-next-line unicorn/prefer-add-event-listener subscriber.onclose = function (error) { onclose?.call(this, error) report(error) diff --git a/packages/bun/src/redis-ratelimit.test.ts b/packages/bun/src/redis-ratelimit.test.ts index 6ef23dc791..91c8f33ac9 100644 --- a/packages/bun/src/redis-ratelimit.test.ts +++ b/packages/bun/src/redis-ratelimit.test.ts @@ -1,7 +1,9 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' + import { sleep } from '@orpc/shared' import { RedisClient } from 'bun' -import { afterAll, beforeAll, describe, expect, it } from 'bun:test' -import { BunRedisRateLimiter } from './redis-ratelimit' + +import { BunRedisRateLimiter } from './redis-ratelimit.ts' const REDIS_URL = Bun.env.REDIS_URL @@ -28,243 +30,268 @@ describe.skipIf(!REDIS_URL)('bun redis rate limiter integration', async () => { }) } - it('accepts weighted requests', async () => { - const limiter = await createTestingRateLimiter() - const key = 'weighted' + it( + 'accepts weighted requests', + async () => { + const limiter = await createTestingRateLimiter() + const key = 'weighted' - await expect( - limiter.limit(key, { weight: 2 }), - ).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 1, - }) - }, { timeout: 20_000 }) + await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 1, + }) + }, + { timeout: 20_000 }, + ) - it('tracks counters independently for each key', async () => { - const limiter = await createTestingRateLimiter() + it( + 'tracks counters independently for each key', + async () => { + const limiter = await createTestingRateLimiter() - const aliceKey = 'alice' - const bobKey = 'bob' + const aliceKey = 'alice' + const bobKey = 'bob' - await expect( - limiter.limit(aliceKey), - ).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 2, - }) + await expect(limiter.limit(aliceKey)).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 2, + }) - await expect( - limiter.limit(bobKey), - ).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 2, - }) - }, { timeout: 20_000 }) + await expect(limiter.limit(bobKey)).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 2, + }) + }, + { timeout: 20_000 }, + ) - it('shares counters with limiters created by prefix', async () => { - const limiter = await createTestingRateLimiter({ maxRequests: 1, window: 10_000 }) + it( + 'shares counters with limiters created by prefix', + async () => { + const limiter = await createTestingRateLimiter({ maxRequests: 1, window: 10_000 }) - await expect(limiter.limit('tenant:key')).resolves.toMatchObject({ success: true, remaining: 0 }) - await expect(limiter.prefix('tenant:').limit('key')).resolves.toMatchObject({ success: false, remaining: 0 }) - await expect(limiter.prefix('other:').limit('key')).resolves.toMatchObject({ success: true, remaining: 0 }) - }, { timeout: 20_000 }) + await expect(limiter.limit('tenant:key')).resolves.toMatchObject({ + success: true, + remaining: 0, + }) + await expect(limiter.prefix('tenant:').limit('key')).resolves.toMatchObject({ + success: false, + remaining: 0, + }) + await expect(limiter.prefix('other:').limit('key')).resolves.toMatchObject({ + success: true, + remaining: 0, + }) + }, + { timeout: 20_000 }, + ) - it('returns a stable reset timestamp within the same window', async () => { - const window = 2000 - const limiter = await createTestingRateLimiter({ - maxRequests: 2, - window, - }) - const key = 'reset' - const tolerance = 500 + it( + 'returns a stable reset timestamp within the same window', + async () => { + const window = 2000 + const limiter = await createTestingRateLimiter({ + maxRequests: 2, + window, + }) + const key = 'reset' + const tolerance = 500 - const firstStartedAt = Date.now() - const first = await limiter.limit(key) - const firstCompletedAt = Date.now() + const firstStartedAt = Date.now() + const first = await limiter.limit(key) + const firstCompletedAt = Date.now() - expect(first).toMatchObject({ - success: true, - limit: 2, - remaining: 1, - }) - expect(first.reset).toBeGreaterThanOrEqual(firstStartedAt + window - tolerance) - expect(first.reset).toBeLessThanOrEqual(firstCompletedAt + window) + expect(first).toMatchObject({ + success: true, + limit: 2, + remaining: 1, + }) + expect(first.reset).toBeGreaterThanOrEqual(firstStartedAt + window - tolerance) + expect(first.reset).toBeLessThanOrEqual(firstCompletedAt + window) - await sleep(100) + await sleep(100) - const second = await limiter.limit(key) + const second = await limiter.limit(key) - expect(second).toMatchObject({ - success: true, - limit: 2, - remaining: 0, - }) - expect(Math.abs(second.reset - first.reset)).toBeLessThanOrEqual(tolerance) + expect(second).toMatchObject({ + success: true, + limit: 2, + remaining: 0, + }) + expect(Math.abs(second.reset - first.reset)).toBeLessThanOrEqual(tolerance) - const denied = await limiter.limit(key) + const denied = await limiter.limit(key) - expect(denied).toMatchObject({ - success: false, - limit: 2, - remaining: 0, - }) - expect(Math.abs(denied.reset - first.reset)).toBeLessThanOrEqual(tolerance) - }, { timeout: 20_000 }) + expect(denied).toMatchObject({ + success: false, + limit: 2, + remaining: 0, + }) + expect(Math.abs(denied.reset - first.reset)).toBeLessThanOrEqual(tolerance) + }, + { timeout: 20_000 }, + ) - it('uses an empty prefix when none is provided', async () => { - const limiter = await createTestingRateLimiter({ - prefix: undefined, - maxRequests: 3, - window: 10_000, - }) + it( + 'uses an empty prefix when none is provided', + async () => { + const limiter = await createTestingRateLimiter({ + prefix: undefined, + maxRequests: 3, + window: 10_000, + }) - await expect( - limiter.limit(`no-prefix-${crypto.randomUUID()}`), - ).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 2, - }) - }, { timeout: 20_000 }) + await expect(limiter.limit(`no-prefix-${crypto.randomUUID()}`)).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 2, + }) + }, + { timeout: 20_000 }, + ) - it('rejects invalid weights', async () => { - const limiter = await createTestingRateLimiter() - const key = 'invalid-weight' + it( + 'rejects invalid weights', + async () => { + const limiter = await createTestingRateLimiter() + const key = 'invalid-weight' - await expect( - limiter.limit(key, { weight: 0 }), - ).rejects.toThrow(TypeError) + await expect(limiter.limit(key, { weight: 0 })).rejects.toThrow(TypeError) - await expect( - limiter.limit(key, { weight: -1 }), - ).rejects.toThrow(TypeError) + await expect(limiter.limit(key, { weight: -1 })).rejects.toThrow(TypeError) - await expect( - limiter.limit(key, { weight: 1.5 }), - ).rejects.toThrow( - 'Rate limit weight must be an integer greater than 0', - ) - }, { timeout: 20_000 }) + await expect(limiter.limit(key, { weight: 1.5 })).rejects.toThrow( + 'Rate limit weight must be an integer greater than 0', + ) + }, + { timeout: 20_000 }, + ) - it('consumes quota across multiple requests', async () => { - const limiter = await createTestingRateLimiter() - const key = 'quota' + it( + 'consumes quota across multiple requests', + async () => { + const limiter = await createTestingRateLimiter() + const key = 'quota' - await expect(limiter.limit(key)) - .resolves - .toMatchObject({ + await expect(limiter.limit(key)).resolves.toMatchObject({ success: true, remaining: 2, }) - await expect(limiter.limit(key)) - .resolves - .toMatchObject({ + await expect(limiter.limit(key)).resolves.toMatchObject({ success: true, remaining: 1, }) - await expect(limiter.limit(key)) - .resolves - .toMatchObject({ + await expect(limiter.limit(key)).resolves.toMatchObject({ success: true, remaining: 0, }) - }, { timeout: 20_000 }) - - it('rejects requests that exceed the limit', async () => { - const limiter = await createTestingRateLimiter() - const key = 'exceeded' - - await limiter.limit(key) - await limiter.limit(key) - await limiter.limit(key) - - await expect( - limiter.limit(key), - ).resolves.toMatchObject({ - success: false, - limit: 3, - remaining: 0, - }) - }, { timeout: 20_000 }) - - describe('timeout', () => { - it('waits until the next window when capacity is unavailable', async () => { - const limiter = await createTestingRateLimiter({ - maxRequests: 2, - window: 1000, - timeout: 4000, - }) + }, + { timeout: 20_000 }, + ) - const key = 'blocking' + it( + 'rejects requests that exceed the limit', + async () => { + const limiter = await createTestingRateLimiter() + const key = 'exceeded' + await limiter.limit(key) await limiter.limit(key) await limiter.limit(key) - const start = Date.now() + await expect(limiter.limit(key)).resolves.toMatchObject({ + success: false, + limit: 3, + remaining: 0, + }) + }, + { timeout: 20_000 }, + ) - const result = await limiter.limit(key) + describe('timeout', () => { + it( + 'waits until the next window when capacity is unavailable', + async () => { + const limiter = await createTestingRateLimiter({ + maxRequests: 2, + window: 1000, + timeout: 4000, + }) - expect(result).toMatchObject({ - success: true, - limit: 2, - }) + const key = 'blocking' - expect(Date.now() - start).toBeGreaterThanOrEqual(900) - expect(result.reset - Date.now()).toBeGreaterThanOrEqual(700) - }, { timeout: 20_000 }) + await limiter.limit(key) + await limiter.limit(key) - it('waits until enough quota is available for weighted requests', async () => { - const limiter = await createTestingRateLimiter({ - maxRequests: 2, - window: 1000, - timeout: 4000, - }) + const start = Date.now() - const key = 'blocking-weighted' + const result = await limiter.limit(key) - await expect( - limiter.limit(key), - ).resolves.toMatchObject({ - success: true, - remaining: 1, - }) + expect(result).toMatchObject({ + success: true, + limit: 2, + }) - await sleep(100) + expect(Date.now() - start).toBeGreaterThanOrEqual(900) + expect(result.reset - Date.now()).toBeGreaterThanOrEqual(700) + }, + { timeout: 20_000 }, + ) - await expect( - limiter.limit(key, { weight: 2 }), - ).resolves.toMatchObject({ - success: true, - limit: 2, - remaining: 0, - }) - }, { timeout: 20_000 }) + it( + 'waits until enough quota is available for weighted requests', + async () => { + const limiter = await createTestingRateLimiter({ + maxRequests: 2, + window: 1000, + timeout: 4000, + }) + + const key = 'blocking-weighted' + + await expect(limiter.limit(key)).resolves.toMatchObject({ + success: true, + remaining: 1, + }) + + await sleep(100) + + await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ + success: true, + limit: 2, + remaining: 0, + }) + }, + { timeout: 20_000 }, + ) - it('returns denial when reset exceeds timeout', async () => { - const limiter = await createTestingRateLimiter({ - maxRequests: 2, - window: 10_000, - timeout: 100, - }) + it( + 'returns denial when reset exceeds timeout', + async () => { + const limiter = await createTestingRateLimiter({ + maxRequests: 2, + window: 10_000, + timeout: 100, + }) - const key = 'blocking-timeout' + const key = 'blocking-timeout' - await limiter.limit(key) + await limiter.limit(key) - await sleep(100) + await sleep(100) - await expect( - limiter.limit(key, { weight: 2 }), - ).resolves.toMatchObject({ - success: false, - limit: 2, - remaining: 0, - }) - }, { timeout: 20_000 }) + await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ + success: false, + limit: 2, + remaining: 0, + }) + }, + { timeout: 20_000 }, + ) }) }) diff --git a/packages/bun/src/redis-ratelimit.ts b/packages/bun/src/redis-ratelimit.ts index b2d7fbc464..3a9a3fc0f2 100644 --- a/packages/bun/src/redis-ratelimit.ts +++ b/packages/bun/src/redis-ratelimit.ts @@ -1,6 +1,6 @@ import type { BaseRedisRateLimiterOptions } from '@orpc/ratelimit/base-redis' -import type { RedisClient } from 'bun' import { BaseRedisRateLimiter } from '@orpc/ratelimit/base-redis' +import type { RedisClient } from 'bun' export interface BunRedisRateLimiterOptions extends BaseRedisRateLimiterOptions {} @@ -24,7 +24,10 @@ export class BunRedisRateLimiter extends BaseRedisRateLimiter { * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} */ prefix(prefix: string): BunRedisRateLimiter { - return new BunRedisRateLimiter(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + return new BunRedisRateLimiter(this.redis, { + ...this.options, + prefix: `${this.keyPrefix}${prefix}`, + }) } protected async evalScript(script: string, keys: string[], args: string[]): Promise { diff --git a/packages/bun/tests/__shared__/utils.ts b/packages/bun/tests/__shared__/utils.ts index 3172a65d73..ae7d83a093 100644 --- a/packages/bun/tests/__shared__/utils.ts +++ b/packages/bun/tests/__shared__/utils.ts @@ -1,12 +1,14 @@ -export async function waitFor(fn: () => void, { timeout = 1000, interval = 50 }: { timeout?: number, interval?: number } = {}): Promise { +export async function waitFor( + fn: () => void | Promise, + { timeout = 1000, interval = 50 }: { timeout?: number; interval?: number } = {}, +): Promise { const deadline = Date.now() + timeout let lastError: unknown while (Date.now() < deadline) { try { await fn() return - } - catch (e) { + } catch (e) { lastError = e await Bun.sleep(interval) } diff --git a/packages/bun/tests/capture-async-context.test.ts b/packages/bun/tests/capture-async-context.test.ts index 812032f739..292ac48f47 100644 --- a/packages/bun/tests/capture-async-context.test.ts +++ b/packages/bun/tests/capture-async-context.test.ts @@ -1,6 +1,7 @@ +import { describe, expect, it } from 'bun:test' import { AsyncLocalStorage } from 'node:async_hooks' + import { captureAsyncContext } from '@orpc/shared' -import { describe, expect, it } from 'bun:test' describe('captureAsyncContext', () => { const storage = new AsyncLocalStorage() @@ -17,9 +18,11 @@ describe('captureAsyncContext', () => { it('keeps each captured async context once the code gets hot', async () => { for (let round = 0; round < 100; round++) { const ids = Array.from({ length: 100 }, (_, i) => round * 100 + i) - const runs = ids.map(id => storage.run(id, () => captureAsyncContext())) + const runs = ids.map((id) => storage.run(id, () => captureAsyncContext())) - const stores = await storage.run(-1, () => Promise.all(runs.map(run => run(async () => storage.getStore())))) + const stores = await storage.run(-1, () => + Promise.all(runs.map((run) => run(async () => storage.getStore()))), + ) expect(stores).toEqual(ids) } diff --git a/packages/bun/tests/lock-redis-adapters-compatibility.test.ts b/packages/bun/tests/lock-redis-adapters-compatibility.test.ts index 80105e1f2d..59950fbcd4 100644 --- a/packages/bun/tests/lock-redis-adapters-compatibility.test.ts +++ b/packages/bun/tests/lock-redis-adapters-compatibility.test.ts @@ -1,22 +1,24 @@ +import { afterAll, describe, expect, it } from 'bun:test' + import type { Locker } from '@orpc/experimental-lock' import { RedisLocker } from '@orpc/experimental-lock/redis' import { promiseWithResolvers } from '@orpc/shared' import { RedisClient } from 'bun' -import { afterAll, describe, expect, it } from 'bun:test' import { createClient } from 'redis' -import { experimental_BunRedisLocker as BunRedisLocker } from '../src' + +import { experimental_BunRedisLocker as BunRedisLocker } from '../src/index.ts' const REDIS_URL = Bun.env.REDIS_URL describe.concurrent('lock redis adapters compatibility', async () => { - const lockers: Array<{ name: string, locker: Locker }> = [] + const lockers: Array<{ name: string; locker: Locker }> = [] const prefix = `redis-adapters:${crypto.randomUUID()}` if (REDIS_URL) { const redis = createClient({ url: REDIS_URL }) afterAll(() => { - redis.close() + void redis.close() }) lockers.push({ @@ -73,19 +75,23 @@ describe.concurrent('lock redis adapters compatibility', async () => { continue } - it(`shares lock state: ${source.name} → ${target.name}`, async () => { - const key = `shared:${crypto.randomUUID()}` - const holder = await hold(source.locker, key) - const waiter = target.locker.lock(key, ({ waited }) => waited) + it( + `shares lock state: ${source.name} → ${target.name}`, + async () => { + const key = `shared:${crypto.randomUUID()}` + const holder = await hold(source.locker, key) + const waiter = target.locker.lock(key, ({ waited }) => waited) - // Commands on one client run in order, so the waiter's first attempt was rejected as well - await expect( - target.locker.lock(key, () => 'never', { timeout: 0 }), - ).rejects.toMatchObject({ name: 'LockTimeoutError', key }) + // Commands on one client run in order, so the waiter's first attempt was rejected as well + await expect( + target.locker.lock(key, () => 'never', { timeout: 0 }), + ).rejects.toMatchObject({ name: 'LockTimeoutError', key }) - await holder.release() - await expect(waiter).resolves.toBe(true) - }, { timeout: 20_000 }) + await holder.release() + await expect(waiter).resolves.toBe(true) + }, + { timeout: 20_000 }, + ) } } }) diff --git a/packages/bun/tests/publisher-redis-adapters-compatibility.test.ts b/packages/bun/tests/publisher-redis-adapters-compatibility.test.ts index ce45cd222c..c4fb6d41a6 100644 --- a/packages/bun/tests/publisher-redis-adapters-compatibility.test.ts +++ b/packages/bun/tests/publisher-redis-adapters-compatibility.test.ts @@ -1,11 +1,13 @@ +import { afterAll, describe, expect, it, vi } from 'bun:test' + import type { Publisher } from '@orpc/publisher' import { RedisPublisher } from '@orpc/publisher/redis' import { getEventMeta, withEventMeta } from '@standard-server/core' import { RedisClient } from 'bun' -import { afterAll, describe, expect, it, vi } from 'bun:test' import { createClient } from 'redis' -import { BunRedisPublisher } from '../src/redis-publisher' -import { waitFor } from './__shared__/utils' + +import { BunRedisPublisher } from '../src/redis-publisher.ts' +import { waitFor } from './__shared__/utils.ts' const REDIS_URL = process.env.REDIS_URL @@ -16,14 +18,14 @@ const REDIS_URL = process.env.REDIS_URL * When adding new tests, always use unique keys to avoid conflicts with other cases. */ describe('publisher redis adapters compatibility', () => { - const publishers: Array<{ name: string, publisher: Publisher }> = [] + const publishers: Array<{ name: string; publisher: Publisher }> = [] const prefix = `redis-adapters:${crypto.randomUUID()}` if (REDIS_URL) { const redis = createClient({ url: REDIS_URL }) afterAll(() => { - redis.close() + void redis.close() }) publishers.push({ @@ -56,50 +58,57 @@ describe('publisher redis adapters compatibility', () => { continue } - it(`delivers live events from ${source.name} to ${target.name} and resumes from the last received id`, async () => { - const event = `live:${crypto.randomUUID()}` - const liveListener = vi.fn() + it( + `delivers live events from ${source.name} to ${target.name} and resumes from the last received id`, + async () => { + const event = `live:${crypto.randomUUID()}` + const liveListener = vi.fn() - const unsubscribeLive = await target.publisher.subscribe(event, liveListener) + const unsubscribeLive = await target.publisher.subscribe(event, liveListener) - await source.publisher.publish(event, withEventMeta({ order: 1 }, { id: 'client-id', comments: ['cross-adapter'] })) - await source.publisher.publish(event, { order: 2 }) + await source.publisher.publish( + event, + withEventMeta({ order: 1 }, { id: 'client-id', comments: ['cross-adapter'] }), + ) + await source.publisher.publish(event, { order: 2 }) - await waitFor(() => { - expect(liveListener).toHaveBeenCalledTimes(2) - }) + await waitFor(() => { + expect(liveListener).toHaveBeenCalledTimes(2) + }) - const firstDelivered = liveListener.mock.calls[0]![0] - const secondDelivered = liveListener.mock.calls[1]![0] + const firstDelivered = liveListener.mock.calls[0]![0] + const secondDelivered = liveListener.mock.calls[1]![0] - expect(firstDelivered).toEqual({ order: 1 }) - expect(getEventMeta(firstDelivered)?.id).toEqual(expect.any(String)) - expect(getEventMeta(firstDelivered)?.id).not.toBe('client-id') - expect(getEventMeta(firstDelivered)?.comments).toEqual(['cross-adapter']) + expect(firstDelivered).toEqual({ order: 1 }) + expect(getEventMeta(firstDelivered)?.id).toEqual(expect.any(String)) + expect(getEventMeta(firstDelivered)?.id).not.toBe('client-id') + expect(getEventMeta(firstDelivered)?.comments).toEqual(['cross-adapter']) - expect(secondDelivered).toEqual({ order: 2 }) - expect(getEventMeta(secondDelivered)?.id).toEqual(expect.any(String)) + expect(secondDelivered).toEqual({ order: 2 }) + expect(getEventMeta(secondDelivered)?.id).toEqual(expect.any(String)) - await unsubscribeLive() + await unsubscribeLive() - await source.publisher.publish(event, { order: 3 }) + await source.publisher.publish(event, { order: 3 }) - const resumed = vi.fn() - const unsubscribeResume = await target.publisher.subscribe(event, resumed, { - lastEventId: getEventMeta(secondDelivered)?.id, - }) + const resumed = vi.fn() + const unsubscribeResume = await target.publisher.subscribe(event, resumed, { + lastEventId: getEventMeta(secondDelivered)?.id, + }) - await waitFor(() => { - expect(resumed).toHaveBeenCalledTimes(1) - }) + await waitFor(() => { + expect(resumed).toHaveBeenCalledTimes(1) + }) - const resumedPayload = resumed.mock.calls[0]![0] + const resumedPayload = resumed.mock.calls[0]![0] - expect(resumedPayload).toEqual({ order: 3 }) - expect(getEventMeta(resumedPayload)?.id).toEqual(expect.any(String)) + expect(resumedPayload).toEqual({ order: 3 }) + expect(getEventMeta(resumedPayload)?.id).toEqual(expect.any(String)) - await unsubscribeResume() - }, { timeout: 20_000 }) + await unsubscribeResume() + }, + { timeout: 20_000 }, + ) } } }) diff --git a/packages/bun/tests/ratelimit-redis-adapters-compatibility.test.ts b/packages/bun/tests/ratelimit-redis-adapters-compatibility.test.ts index 390f53e1c8..7ca9c423c2 100644 --- a/packages/bun/tests/ratelimit-redis-adapters-compatibility.test.ts +++ b/packages/bun/tests/ratelimit-redis-adapters-compatibility.test.ts @@ -1,21 +1,23 @@ +import { afterAll, describe, expect, it } from 'bun:test' + import type { RateLimiter } from '@orpc/ratelimit' import { RedisRateLimiter } from '@orpc/ratelimit/redis' import { RedisClient } from 'bun' -import { afterAll, describe, expect, it } from 'bun:test' import { createClient } from 'redis' -import { BunRedisRateLimiter } from '../src' + +import { BunRedisRateLimiter } from '../src/index.ts' const REDIS_URL = Bun.env.REDIS_URL describe.concurrent('ratelimit redis adapters compatibility', async () => { - const limiters: Array<{ name: string, limiter: RateLimiter }> = [] + const limiters: Array<{ name: string; limiter: RateLimiter }> = [] const prefix = `redis-adapters:${crypto.randomUUID()}` if (REDIS_URL) { const redis = createClient({ url: REDIS_URL }) afterAll(() => { - redis.close() + void redis.close() }) limiters.push({ diff --git a/packages/bun/tests/rpc/__shared__/built-in-support-data-types.ts b/packages/bun/tests/rpc/__shared__/built-in-support-data-types.ts index 670d828074..fcf765713c 100644 --- a/packages/bun/tests/rpc/__shared__/built-in-support-data-types.ts +++ b/packages/bun/tests/rpc/__shared__/built-in-support-data-types.ts @@ -10,7 +10,7 @@ enum Test { /** * The data types that oRPC/rpc guarantees to be supported. */ -export const builtInRPCSupportDataTypes: { name: string, value: unknown, expected: unknown }[] = [ +export const builtInRPCSupportDataTypes: { name: string; value: unknown; expected: unknown }[] = [ { name: 'enum', value: Test.B, @@ -93,8 +93,14 @@ export const builtInRPCSupportDataTypes: { name: string, value: unknown, expecte }, { name: 'map', - value: new Map([[1, 2], [3, 4]]), - expected: new Map([[1, 2], [3, 4]]), + value: new Map([ + [1, 2], + [3, 4], + ]), + expected: new Map([ + [1, 2], + [3, 4], + ]), }, { name: 'set', diff --git a/packages/bun/tests/rpc/__shared__/client-server.bun-fetch.ts b/packages/bun/tests/rpc/__shared__/client-server.bun-fetch.ts index eed4230296..b404b8bca2 100644 --- a/packages/bun/tests/rpc/__shared__/client-server.bun-fetch.ts +++ b/packages/bun/tests/rpc/__shared__/client-server.bun-fetch.ts @@ -1,9 +1,11 @@ -import type { CreateClientServerTest } from './client-server' +import { afterAll } from 'bun:test' + import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' import { RPCHandler } from '@orpc/server/fetch' -import { afterAll } from 'bun:test' -import { defaultSerializer } from './client-server' + +import type { CreateClientServerTest } from './client-server.ts' +import { defaultSerializer } from './client-server.ts' export const createBunFetchClientServerTest: CreateClientServerTest = ( router, @@ -26,7 +28,7 @@ export const createBunFetchClientServerTest: CreateClientServerTest = ( }) afterAll(() => { - server.stop() + void server.stop() }) const link = new RPCLink({ diff --git a/packages/bun/tests/rpc/__shared__/client-server.bun-websocket.ts b/packages/bun/tests/rpc/__shared__/client-server.bun-websocket.ts index 796bd216a3..ce687e355b 100644 --- a/packages/bun/tests/rpc/__shared__/client-server.bun-websocket.ts +++ b/packages/bun/tests/rpc/__shared__/client-server.bun-websocket.ts @@ -1,9 +1,11 @@ -import type { CreateClientServerTest } from './client-server' +import { afterAll } from 'bun:test' + import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/websocket' import { RPCHandler } from '@orpc/server/websocket' -import { afterAll } from 'bun:test' -import { defaultSerializer } from './client-server' + +import type { CreateClientServerTest } from './client-server.ts' +import { defaultSerializer } from './client-server.ts' export const createBunWebSocketClientServerTest: CreateClientServerTest = ( router, @@ -18,7 +20,7 @@ export const createBunWebSocketClientServerTest: CreateClientServerTest = ( const server = Bun.serve({ fetch(req, server) { if (server.upgrade(req)) { - return + return undefined } return new Response('Upgrade failed', { status: 500 }) }, @@ -37,7 +39,7 @@ export const createBunWebSocketClientServerTest: CreateClientServerTest = ( }) afterAll(() => { - server.stop(true) + void server.stop(true) }) const link = new RPCLink({ diff --git a/packages/bun/tests/rpc/__shared__/client-server.compression-bun-fetch.ts b/packages/bun/tests/rpc/__shared__/client-server.compression-bun-fetch.ts index af5e99ac32..467ad889ed 100644 --- a/packages/bun/tests/rpc/__shared__/client-server.compression-bun-fetch.ts +++ b/packages/bun/tests/rpc/__shared__/client-server.compression-bun-fetch.ts @@ -1,11 +1,16 @@ -import type { CreateClientServerTest } from './client-server' +import { afterAll } from 'bun:test' + import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' import { RequestCompressionLinkPlugin } from '@orpc/client/plugins' import { RPCHandler } from '@orpc/server/fetch' -import { RequestCompressionHandlerPlugin, ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' -import { afterAll } from 'bun:test' -import { defaultSerializer } from './client-server' +import { + RequestCompressionHandlerPlugin, + ResponseCompressionHandlerPlugin, +} from '@orpc/server/plugins' + +import type { CreateClientServerTest } from './client-server.ts' +import { defaultSerializer } from './client-server.ts' export const createCompressionBunFetchClientServerTest: CreateClientServerTest = ( router, @@ -35,7 +40,7 @@ export const createCompressionBunFetchClientServerTest: CreateClientServerTest = }) afterAll(() => { - server.stop() + void server.stop() }) const link = new RPCLink({ diff --git a/packages/bun/tests/rpc/__shared__/client-server.compression-bun-websocket.ts b/packages/bun/tests/rpc/__shared__/client-server.compression-bun-websocket.ts index f88e3f010b..e6e9158497 100644 --- a/packages/bun/tests/rpc/__shared__/client-server.compression-bun-websocket.ts +++ b/packages/bun/tests/rpc/__shared__/client-server.compression-bun-websocket.ts @@ -1,11 +1,16 @@ -import type { CreateClientServerTest } from './client-server' +import { afterAll } from 'bun:test' + import { createORPCClient } from '@orpc/client' import { RequestCompressionLinkPlugin, ResponseCompressionLinkPlugin } from '@orpc/client/plugins' import { RPCLink } from '@orpc/client/websocket' -import { RequestCompressionHandlerPlugin, ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' +import { + RequestCompressionHandlerPlugin, + ResponseCompressionHandlerPlugin, +} from '@orpc/server/plugins' import { RPCHandler } from '@orpc/server/websocket' -import { afterAll } from 'bun:test' -import { defaultSerializer } from './client-server' + +import type { CreateClientServerTest } from './client-server.ts' +import { defaultSerializer } from './client-server.ts' export const createCompressionBunWebSocketClientServerTest: CreateClientServerTest = ( router, @@ -27,7 +32,7 @@ export const createCompressionBunWebSocketClientServerTest: CreateClientServerTe const server = Bun.serve({ fetch(req, server) { if (server.upgrade(req)) { - return + return undefined } return new Response('Upgrade failed', { status: 500 }) }, @@ -46,7 +51,7 @@ export const createCompressionBunWebSocketClientServerTest: CreateClientServerTe }) afterAll(() => { - server.stop(true) + void server.stop(true) }) const link = new RPCLink({ diff --git a/packages/bun/tests/rpc/__shared__/client-server.ts b/packages/bun/tests/rpc/__shared__/client-server.ts index 6d60122eb2..16344ab7eb 100644 --- a/packages/bun/tests/rpc/__shared__/client-server.ts +++ b/packages/bun/tests/rpc/__shared__/client-server.ts @@ -1,6 +1,6 @@ +import { RPCSerializer } from '@orpc/client' import type { AnyRouter, Context, RouterClient } from '@orpc/server' import type { Public } from '@orpc/shared' -import { RPCSerializer } from '@orpc/client' export interface ClientServerTestOptions { context?: Context @@ -23,8 +23,8 @@ export class Person { export const defaultSerializer: RPCSerializer = new RPCSerializer({ handlers: { person: { - condition: value => value instanceof Person, - serialize: person => ({ name: person.name, age: person.age }), + condition: (value) => value instanceof Person, + serialize: (person) => ({ name: person.name, age: person.age }), deserialize: (data: any) => new Person(data.name, data.age), }, }, diff --git a/packages/bun/tests/rpc/cancel-and-abort.test.ts b/packages/bun/tests/rpc/cancel-and-abort.test.ts index 903be0d7c7..378df4f4de 100644 --- a/packages/bun/tests/rpc/cancel-and-abort.test.ts +++ b/packages/bun/tests/rpc/cancel-and-abort.test.ts @@ -1,11 +1,13 @@ +import { describe, expect, it, vi } from 'bun:test' + import { AsyncIteratorClass, os } from '@orpc/server' import { sleep } from 'bun' -import { describe, expect, it, vi } from 'bun:test' import { z } from 'zod' -import { createBunFetchClientServerTest } from './__shared__/client-server.bun-fetch' -import { createBunWebSocketClientServerTest } from './__shared__/client-server.bun-websocket' -import { createCompressionBunFetchClientServerTest } from './__shared__/client-server.compression-bun-fetch' -import { createCompressionBunWebSocketClientServerTest } from './__shared__/client-server.compression-bun-websocket' + +import { createBunFetchClientServerTest } from './__shared__/client-server.bun-fetch.ts' +import { createBunWebSocketClientServerTest } from './__shared__/client-server.bun-websocket.ts' +import { createCompressionBunFetchClientServerTest } from './__shared__/client-server.compression-bun-fetch.ts' +import { createCompressionBunWebSocketClientServerTest } from './__shared__/client-server.compression-bun-websocket.ts' describe.each([ ['bun-fetch', createBunFetchClientServerTest], @@ -40,15 +42,14 @@ describe.each([ it('server should cancel AsyncIteratorObject response and abort request when client cancels', async () => { const cancel = vi.fn() - handler.mockResolvedValueOnce(new AsyncIteratorClass( - async () => { + handler.mockResolvedValueOnce( + new AsyncIteratorClass(async () => { await sleep(200) return { value: 'data: hello\n\n', done: false } - }, - cancel, - )) + }, cancel), + ) - const iterator = await client.ping() as AsyncIteratorClass + const iterator = (await client.ping()) as AsyncIteratorClass await expect(iterator.next()).resolves.toEqual({ value: 'data: hello\n\n', done: false }) @@ -63,30 +64,35 @@ describe.each([ // TODO: https://github.com/oven-sh/bun/issues/33227 is fixed for async iterator (SSE) responses in Bun 1.4, // but cancelling an octet stream response still does not abort the request on the server - it.skipIf(adapter === 'bun-fetch' || adapter === 'compression-bun-fetch')('server should cancel octet stream response and abort request when client cancels', async () => { - const cancel = vi.fn() - handler.mockResolvedValueOnce(new ReadableStream({ - async pull(controller) { - await sleep(200) - controller.enqueue(new TextEncoder().encode('hello')) - }, - cancel, - })) - - const result = await client.ping() as ReadableStream - const reader = result.getReader() - - const first = await reader.read() - expect(first.done).toBe(false) - expect(first.value).toBeInstanceOf(Uint8Array) - expect(new TextDecoder().decode(first.value)).toBe('hello') - - expect(cancel).toHaveBeenCalledTimes(0) - await reader.cancel() - await sleep(100) // lag between client and server - expect(cancel).toHaveBeenCalledTimes(1) - expect(handler.mock.calls[0]![0].signal.aborted).toEqual(true) - - await expect(reader.read()).resolves.toEqual({ value: undefined, done: true }) - }) + it.skipIf(adapter === 'bun-fetch' || adapter === 'compression-bun-fetch')( + 'server should cancel octet stream response and abort request when client cancels', + async () => { + const cancel = vi.fn() + handler.mockResolvedValueOnce( + new ReadableStream({ + async pull(controller) { + await sleep(200) + controller.enqueue(new TextEncoder().encode('hello')) + }, + cancel, + }), + ) + + const result = (await client.ping()) as ReadableStream + const reader = result.getReader() + + const first = await reader.read() + expect(first.done).toBe(false) + expect(first.value).toBeInstanceOf(Uint8Array) + expect(new TextDecoder().decode(first.value)).toBe('hello') + + expect(cancel).toHaveBeenCalledTimes(0) + await reader.cancel() + await sleep(100) // lag between client and server + expect(cancel).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0].signal.aborted).toEqual(true) + + await expect(reader.read()).resolves.toEqual({ value: undefined, done: true }) + }, + ) }) diff --git a/packages/bun/tests/rpc/data-transfer.test.ts b/packages/bun/tests/rpc/data-transfer.test.ts index 70274cf99e..4de9b1d87f 100644 --- a/packages/bun/tests/rpc/data-transfer.test.ts +++ b/packages/bun/tests/rpc/data-transfer.test.ts @@ -1,13 +1,15 @@ +import { describe, expect, it } from 'bun:test' + import { os } from '@orpc/server' import { promiseWithResolvers } from '@orpc/shared' -import { describe, expect, it } from 'bun:test' import { z } from 'zod' -import { builtInRPCSupportDataTypes } from './__shared__/built-in-support-data-types' -import { Person } from './__shared__/client-server' -import { createBunFetchClientServerTest } from './__shared__/client-server.bun-fetch' -import { createBunWebSocketClientServerTest } from './__shared__/client-server.bun-websocket' -import { createCompressionBunFetchClientServerTest } from './__shared__/client-server.compression-bun-fetch' -import { createCompressionBunWebSocketClientServerTest } from './__shared__/client-server.compression-bun-websocket' + +import { builtInRPCSupportDataTypes } from './__shared__/built-in-support-data-types.ts' +import { createBunFetchClientServerTest } from './__shared__/client-server.bun-fetch.ts' +import { createBunWebSocketClientServerTest } from './__shared__/client-server.bun-websocket.ts' +import { createCompressionBunFetchClientServerTest } from './__shared__/client-server.compression-bun-fetch.ts' +import { createCompressionBunWebSocketClientServerTest } from './__shared__/client-server.compression-bun-websocket.ts' +import { Person } from './__shared__/client-server.ts' describe.each([ ['bun-fetch', createBunFetchClientServerTest], @@ -32,8 +34,7 @@ describe.each([ if (typeof expected === 'function') { expect(expected(actual)).toBe(true) - } - else { + } else { expect(actual).toEqual(expected) } }) @@ -52,76 +53,82 @@ describe.each([ // TODO: There an issues with Bun Websocket Server, when multiple messages sent simultaneously // We might need to report this issue - it.skipIf(adapter === 'bun-websocket' || adapter === 'compression-bun-websocket')('support octet stream and transfer octet in parallel', async () => { - const order2 = promiseWithResolvers() - const order3 = promiseWithResolvers() - - const stream = new ReadableStream({ - async start(controller) { - controller.enqueue('order 1') - await order2.promise - controller.enqueue('order 2') - await order3.promise - controller.enqueue('order 3') - controller.close() - }, - }).pipeThrough(new TextEncoderStream()) - - // The source holds everything after its first chunk until the test releases it, so a transport - // that buffered the stream would hang here instead of resolving. - const result = await client.ping(stream) as ReadableStream - - const reader = result.getReader() - - const first = await reader.read() - expect(first.done).toBe(false) - expect(first.value).toBeInstanceOf(Uint8Array) - expect(new TextDecoder().decode(first.value)).toBe('order 1') - - order2.resolve() - const second = await reader.read() - expect(second.done).toBe(false) - expect(second.value).toBeInstanceOf(Uint8Array) - expect(new TextDecoder().decode(second.value)).toBe('order 2') - - order3.resolve() - const third = await reader.read() - expect(third.done).toBe(false) - expect(third.value).toBeInstanceOf(Uint8Array) - expect(new TextDecoder().decode(third.value)).toBe('order 3') - - await expect(reader.read()).resolves.toEqual({ value: undefined, done: true }) - }) + it.skipIf(adapter === 'bun-websocket' || adapter === 'compression-bun-websocket')( + 'support octet stream and transfer octet in parallel', + async () => { + const order2 = promiseWithResolvers() + const order3 = promiseWithResolvers() + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue('order 1') + await order2.promise + controller.enqueue('order 2') + await order3.promise + controller.enqueue('order 3') + controller.close() + }, + }).pipeThrough(new TextEncoderStream()) + + // The source holds everything after its first chunk until the test releases it, so a transport + // that buffered the stream would hang here instead of resolving. + const result = (await client.ping(stream)) as ReadableStream + + const reader = result.getReader() + + const first = await reader.read() + expect(first.done).toBe(false) + expect(first.value).toBeInstanceOf(Uint8Array) + expect(new TextDecoder().decode(first.value)).toBe('order 1') + + order2.resolve() + const second = await reader.read() + expect(second.done).toBe(false) + expect(second.value).toBeInstanceOf(Uint8Array) + expect(new TextDecoder().decode(second.value)).toBe('order 2') + + order3.resolve() + const third = await reader.read() + expect(third.done).toBe(false) + expect(third.value).toBeInstanceOf(Uint8Array) + expect(new TextDecoder().decode(third.value)).toBe('order 3') + + await expect(reader.read()).resolves.toEqual({ value: undefined, done: true }) + }, + ) // TODO: There an issues with Bun Websocket Server, when multiple messages sent simultaneously // We might need to report this issue - it.skipIf(adapter === 'bun-websocket' || adapter === 'compression-bun-websocket')('support AsyncIteratorObject and transfer AsyncIteratorObject in parallel', async () => { - const order2 = promiseWithResolvers() - const order3 = promiseWithResolvers() - const completion = promiseWithResolvers() - - const stream = (async function* () { - yield 'order 1' - await order2.promise - yield { order: 2 } - await order3.promise - yield new Person('Order 3', 3) - await completion.promise - return new Date('2024-01-01') - }()) - - // The source holds everything after its first event until the test releases it, so a transport - // that buffered the stream would hang here instead of resolving. - const result = await client.ping(stream) as AsyncIteratorObject - await expect(result.next()).resolves.toEqual({ value: 'order 1', done: false }) - - order2.resolve() - await expect(result.next()).resolves.toEqual({ value: { order: 2 }, done: false }) - - order3.resolve() - await expect(result.next()).resolves.toEqual({ value: new Person('Order 3', 3), done: false }) - - completion.resolve() - await expect(result.next()).resolves.toEqual({ value: new Date('2024-01-01'), done: true }) - }) + it.skipIf(adapter === 'bun-websocket' || adapter === 'compression-bun-websocket')( + 'support AsyncIteratorObject and transfer AsyncIteratorObject in parallel', + async () => { + const order2 = promiseWithResolvers() + const order3 = promiseWithResolvers() + const completion = promiseWithResolvers() + + const stream = (async function* () { + yield 'order 1' + await order2.promise + yield { order: 2 } + await order3.promise + yield new Person('Order 3', 3) + await completion.promise + return new Date('2024-01-01') + })() + + // The source holds everything after its first event until the test releases it, so a transport + // that buffered the stream would hang here instead of resolving. + const result = (await client.ping(stream)) as AsyncIteratorObject + await expect(result.next()).resolves.toEqual({ value: 'order 1', done: false }) + + order2.resolve() + await expect(result.next()).resolves.toEqual({ value: { order: 2 }, done: false }) + + order3.resolve() + await expect(result.next()).resolves.toEqual({ value: new Person('Order 3', 3), done: false }) + + completion.resolve() + await expect(result.next()).resolves.toEqual({ value: new Date('2024-01-01'), done: true }) + }, + ) }) diff --git a/packages/bun/tsconfig.build.json b/packages/bun/tsconfig.build.json new file mode 100644 index 0000000000..e852723d91 --- /dev/null +++ b/packages/bun/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "types": ["bun"] + } +} diff --git a/packages/bun/tsconfig.json b/packages/bun/tsconfig.json index 998ecdc906..fec03f3346 100644 --- a/packages/bun/tsconfig.json +++ b/packages/bun/tsconfig.json @@ -1,7 +1,9 @@ { - "extends": "../../tsconfig.lib.json", + "extends": "../../tsconfig.json", "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["bun"] }, - "include": ["./package.json", "src", "tests"] + "include": ["src", "tests"], + "exclude": [] } diff --git a/packages/client/README.md b/packages/client/README.md index ec8833451c..2a0c2e2e15 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/client/build.config.ts b/packages/client/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/client/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/client/package.json b/packages/client/package.json index 7d08ab2d0e..83834911dc 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,81 +1,80 @@ { "name": "@orpc/client", - "type": "module", "version": "2.0.0-beta.48", "description": "Consume oRPC and OpenAPI APIs with end-to-end type safety over HTTP, WebSocket, or message ports", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "api", + "client", + "fetch", + "openapi", + "orpc", + "rpc", + "typesafe", + "typescript", + "websocket" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/client" }, - "keywords": [ - "orpc", - "rpc", - "api", - "typescript", - "typesafe", - "client", - "fetch", - "websocket", - "openapi" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./plugins": "./src/plugins/index.ts", + "./standard": "./src/adapters/standard/index.ts", + "./fetch": "./src/adapters/fetch/index.ts", + "./websocket": "./src/adapters/websocket/index.ts", + "./message-port": "./src/adapters/message-port/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./plugins": { - "types": "./dist/plugins/index.d.mts", - "import": "./dist/plugins/index.mjs", - "default": "./dist/plugins/index.mjs" + "types": "./dist/plugins/index.d.ts", + "import": "./dist/plugins/index.js", + "default": "./dist/plugins/index.js" }, "./standard": { - "types": "./dist/adapters/standard/index.d.mts", - "import": "./dist/adapters/standard/index.mjs", - "default": "./dist/adapters/standard/index.mjs" + "types": "./dist/adapters/standard/index.d.ts", + "import": "./dist/adapters/standard/index.js", + "default": "./dist/adapters/standard/index.js" }, "./fetch": { - "types": "./dist/adapters/fetch/index.d.mts", - "import": "./dist/adapters/fetch/index.mjs", - "default": "./dist/adapters/fetch/index.mjs" + "types": "./dist/adapters/fetch/index.d.ts", + "import": "./dist/adapters/fetch/index.js", + "default": "./dist/adapters/fetch/index.js" }, "./websocket": { - "types": "./dist/adapters/websocket/index.d.mts", - "import": "./dist/adapters/websocket/index.mjs", - "default": "./dist/adapters/websocket/index.mjs" + "types": "./dist/adapters/websocket/index.d.ts", + "import": "./dist/adapters/websocket/index.js", + "default": "./dist/adapters/websocket/index.js" }, "./message-port": { - "types": "./dist/adapters/message-port/index.d.mts", - "import": "./dist/adapters/message-port/index.mjs", - "default": "./dist/adapters/message-port/index.mjs" + "types": "./dist/adapters/message-port/index.d.ts", + "import": "./dist/adapters/message-port/index.js", + "default": "./dist/adapters/message-port/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts", - "./plugins": "./src/plugins/index.ts", - "./standard": "./src/adapters/standard/index.ts", - "./fetch": "./src/adapters/fetch/index.ts", - "./websocket": "./src/adapters/websocket/index.ts", - "./message-port": "./src/adapters/message-port/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" + "prepack": "tsc" }, "dependencies": { "@orpc/shared": "workspace:*", diff --git a/packages/client/src/adapters/fetch/index.test.ts b/packages/client/src/adapters/fetch/index.test.ts index fe981df20b..27067b6ced 100644 --- a/packages/client/src/adapters/fetch/index.test.ts +++ b/packages/client/src/adapters/fetch/index.test.ts @@ -1,3 +1,3 @@ it('exports RPCLink', async () => { - await expect(import('.')).resolves.toHaveProperty('RPCLink') + await expect(import('./index.ts')).resolves.toHaveProperty('RPCLink') }) diff --git a/packages/client/src/adapters/fetch/index.ts b/packages/client/src/adapters/fetch/index.ts index 9a7dc93c19..478aa2ad4b 100644 --- a/packages/client/src/adapters/fetch/index.ts +++ b/packages/client/src/adapters/fetch/index.ts @@ -1,2 +1,2 @@ -export * from './rpc-link' -export * from './transport' +export * from './rpc-link.ts' +export * from './transport.ts' diff --git a/packages/client/src/adapters/fetch/rpc-link.test-d.ts b/packages/client/src/adapters/fetch/rpc-link.test-d.ts index 2feffa252d..267dc02113 100644 --- a/packages/client/src/adapters/fetch/rpc-link.test-d.ts +++ b/packages/client/src/adapters/fetch/rpc-link.test-d.ts @@ -1,4 +1,4 @@ -import { RPCLink } from './rpc-link' +import { RPCLink } from './rpc-link.ts' describe('RPCLink', () => { it('accepts QUERY only for body-bearing request methods', () => { diff --git a/packages/client/src/adapters/fetch/rpc-link.test.ts b/packages/client/src/adapters/fetch/rpc-link.test.ts index 5e1b02ac07..4dcd908e08 100644 --- a/packages/client/src/adapters/fetch/rpc-link.test.ts +++ b/packages/client/src/adapters/fetch/rpc-link.test.ts @@ -1,9 +1,10 @@ import { toFetchBody } from '@standard-server/fetch' -import { createORPCClient } from '../../client' -import { RPCLink } from './rpc-link' + +import { createORPCClient } from '../../client.ts' +import { RPCLink } from './rpc-link.ts' vi.mock('@orpc/shared', async (loadOrigin) => { - const origin = await loadOrigin() as any + const origin = (await loadOrigin()) as any return { ...origin, @@ -12,7 +13,7 @@ vi.mock('@orpc/shared', async (loadOrigin) => { }) vi.mock('@standard-server/fetch', async (loadOrigin) => { - const origin = await loadOrigin() as any + const origin = (await loadOrigin()) as any return { ...origin, @@ -35,10 +36,12 @@ describe('rpcLink', () => { }) }) - const orpc = createORPCClient(new RPCLink({ - fetch, - origin: 'http://api.example.com', - })) as any + const orpc = createORPCClient( + new RPCLink({ + fetch, + origin: 'http://api.example.com', + }), + ) as any await expect(orpc.ping('input')).resolves.toEqual('pong') @@ -66,11 +69,13 @@ describe('rpcLink', () => { }) }) - const orpc = createORPCClient(new RPCLink({ - fetch, - method: 'QUERY', - origin: 'http://api.example.com', - })) as any + const orpc = createORPCClient( + new RPCLink({ + fetch, + method: 'QUERY', + origin: 'http://api.example.com', + }), + ) as any await expect(orpc.ping('input')).resolves.toEqual('pong') @@ -97,11 +102,13 @@ describe('rpcLink', () => { 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 + 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') @@ -137,9 +144,11 @@ describe('rpcLink', () => { globalThis.fetch = originalFetch }) - const orpc = createORPCClient(new RPCLink({ - origin: 'http://api.example.com/', - })) as any + const orpc = createORPCClient( + new RPCLink({ + origin: 'http://api.example.com/', + }), + ) as any await expect(orpc.ping('input')).resolves.toEqual('pong') @@ -153,10 +162,14 @@ describe('rpcLink', () => { ) }) - it('uses the current global fetch even when it is patched after link construction', async ({ onTestFinished }) => { - const orpc = createORPCClient(new RPCLink({ - origin: 'http://api.example.com/', - })) as any + it('uses the current global fetch even when it is patched after link construction', async ({ + onTestFinished, + }) => { + const orpc = createORPCClient( + new RPCLink({ + origin: 'http://api.example.com/', + }), + ) as any const fetchSpy = vi.fn(async () => { return new Response(JSON.stringify({ json: 'pong' }), { @@ -196,10 +209,12 @@ describe('rpcLink', () => { }) }) - const orpc = createORPCClient(new RPCLink({ - fetch, - origin: 'http://api.example.com', - })) as any + const orpc = createORPCClient( + new RPCLink({ + fetch, + origin: 'http://api.example.com', + }), + ) as any await expect(orpc.ping('input')).resolves.toEqual('pong') @@ -218,11 +233,13 @@ describe('rpcLink', () => { const toFetchRequestOptions = { eventStream: { keepAlive: { enabled: true, comment: 'ok' } } } - const orpc = createORPCClient(new RPCLink({ - fetch, - origin: 'http://api.example.com', - toFetchRequest: toFetchRequestOptions, - })) as any + const orpc = createORPCClient( + new RPCLink({ + fetch, + origin: 'http://api.example.com', + toFetchRequest: toFetchRequestOptions, + }), + ) as any await expect(orpc.ping('input')).resolves.toEqual('pong') @@ -249,9 +266,11 @@ describe('rpcLink', () => { }) }) - const orpc = createORPCClient(new RPCLink({ - fetch, - })) as any + const orpc = createORPCClient( + new RPCLink({ + fetch, + }), + ) as any await expect(orpc.ping('input')).resolves.toEqual('pong') @@ -279,11 +298,13 @@ describe('rpcLink', () => { }) }) - const orpc = createORPCClient(new RPCLink({ - fetch, - origin: 'http://api.example.com', - toFetchRequest: { eventStream: { keepAlive: { enabled: true, comment: 'ok' } } }, - })) as any + const orpc = createORPCClient( + new RPCLink({ + fetch, + origin: 'http://api.example.com', + toFetchRequest: { eventStream: { keepAlive: { enabled: true, comment: 'ok' } } }, + }), + ) as any await expect(orpc.ping(new ReadableStream())).resolves.toEqual('pong') @@ -302,7 +323,9 @@ describe('rpcLink', () => { ) }) - it('does not set duplex mode for ReadableStream body when not supported', async ({ onTestFinished }) => { + it('does not set duplex mode for ReadableStream body when not supported', async ({ + onTestFinished, + }) => { const OriginalRequest = globalThis.Request ;(globalThis as any).Request = class ThrowsDuplex extends OriginalRequest { constructor(input: any, init?: any) { @@ -314,7 +337,7 @@ describe('rpcLink', () => { } onTestFinished(() => { - (globalThis as any).Request = OriginalRequest + ;(globalThis as any).Request = OriginalRequest }) const fetch = vi.fn(async () => { @@ -326,11 +349,13 @@ describe('rpcLink', () => { }) }) - const orpc = createORPCClient(new RPCLink({ - fetch, - origin: 'http://api.example.com', - toFetchRequest: { eventStream: { keepAlive: { enabled: true, comment: 'ok' } } }, - })) as any + const orpc = createORPCClient( + new RPCLink({ + fetch, + origin: 'http://api.example.com', + toFetchRequest: { eventStream: { keepAlive: { enabled: true, comment: 'ok' } } }, + }), + ) as any await expect(orpc.ping(new ReadableStream())).resolves.toEqual('pong') @@ -341,7 +366,10 @@ describe('rpcLink', () => { it.each([ ['opaqueredirect', 'Unexpected redirect response: redirects are not followed.'], - ['opaque', 'Unexpected opaque response: the response is unreadable, likely due to a no-cors request.'], + [ + 'opaque', + 'Unexpected opaque response: the response is unreadable, likely due to a no-cors request.', + ], ])('throws on %s responses', async (type, message) => { const fetch = vi.fn(async () => { // Browsers return opaque responses with status 0 and no headers or body @@ -350,10 +378,12 @@ describe('rpcLink', () => { return response }) - const orpc = createORPCClient(new RPCLink({ - fetch, - origin: 'http://api.example.com', - })) as any + const orpc = createORPCClient( + new RPCLink({ + fetch, + origin: 'http://api.example.com', + }), + ) as any await expect(orpc.ping('input')).rejects.toThrow(new TypeError(message)) expect(fetch).toHaveBeenCalledOnce() diff --git a/packages/client/src/adapters/fetch/rpc-link.ts b/packages/client/src/adapters/fetch/rpc-link.ts index 6b23692cdd..0f7785cfc1 100644 --- a/packages/client/src/adapters/fetch/rpc-link.ts +++ b/packages/client/src/adapters/fetch/rpc-link.ts @@ -1,12 +1,11 @@ -import type { ClientContext } from '../../types' -import type { RPCLinkCodecOptions, StandardLinkOptions } from '../standard' -import type { FetchLinkTransportOptions } from './transport' -import { RPCLinkCodec, StandardLink } from '../standard' -import { FetchLinkTransport } from './transport' +import type { ClientContext } from '../../types.ts' +import type { RPCLinkCodecOptions, StandardLinkOptions } from '../standard/index.ts' +import { RPCLinkCodec, StandardLink } from '../standard/index.ts' +import type { FetchLinkTransportOptions } from './transport.ts' +import { FetchLinkTransport } from './transport.ts' export interface RPCLinkOptions - extends StandardLinkOptions, FetchLinkTransportOptions, RPCLinkCodecOptions { -} + extends StandardLinkOptions, FetchLinkTransportOptions, RPCLinkCodecOptions {} /** * Client link that communicates with an RPC Handler over the Fetch API (HTTP). diff --git a/packages/client/src/adapters/fetch/transport.ts b/packages/client/src/adapters/fetch/transport.ts index e6a15c0488..82d3a7e2f2 100644 --- a/packages/client/src/adapters/fetch/transport.ts +++ b/packages/client/src/adapters/fetch/transport.ts @@ -1,31 +1,28 @@ import type { Promisable, Value } from '@orpc/shared' +import { once, value } from '@orpc/shared' import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' import type { ToFetchBodyOptions } from '@standard-server/fetch' -import type { ClientContext, ClientOptions } from '../../types' -import type { StandardLinkTransport } from '../standard' -import { once, value } from '@orpc/shared' import { toFetchBody, toFetchHeaders, toStandardLazyResponse } from '@standard-server/fetch' +import type { ClientContext, ClientOptions } from '../../types.ts' +import type { StandardLinkTransport } from '../standard/index.ts' + const GET_SUPPORTED_DUPLEX_MODE = /* @__PURE__ */ once(() => { // TODO: Try `duplex: 'full'` when it is widely supported. try { let duplex: 'half' | undefined - void new Request( - 'https://example.com', - { - method: 'POST', - body: new ReadableStream(), - get duplex() { - duplex = 'half' - return 'half' - }, - } as any, - ) + void new Request('https://example.com', { + method: 'POST', + body: new ReadableStream(), + get duplex() { + duplex = 'half' + return 'half' + }, + } as any) return duplex - } - catch { + } catch { return undefined } }) @@ -37,7 +34,12 @@ export interface FetchLinkTransportOptions { * @example 'https://api.example.com' * @example 'http://localhost:3000' */ - origin?: Value, [options: ClientOptions, path: string[]]> | undefined + origin?: + | Value< + Promisable<`https://${string}` | `http://${string}` | ({} & string) | undefined>, + [options: ClientOptions, path: string[]] + > + | undefined /** * Options for how to convert the Standard Request to a Fetch Request, like event stream options, etc. @@ -49,7 +51,12 @@ export interface FetchLinkTransportOptions { * * @default (url, init) => globalThis.fetch(url, init) */ - fetch?(url: string, init: RequestInit, options: ClientOptions, path: string[]): Promise + fetch?( + url: string, + init: RequestInit, + options: ClientOptions, + path: string[], + ): Promise } export class FetchLinkTransport implements StandardLinkTransport { @@ -61,18 +68,26 @@ export class FetchLinkTransport implements StandardLink this.origin = options.origin // Resolve `globalThis.fetch` lazily so interception tools (msw, undici MockAgent, etc.) // that patch it after this transport is constructed still take effect. - this.fetch = options.fetch ?? ((url, init) => (globalThis.fetch)(url, init)) + this.fetch = options.fetch ?? ((url, init) => globalThis.fetch(url, init)) this.toFetchRequestOptions = options.toFetchRequest } - async send(standardRequest: StandardRequest, path: string[], options: ClientOptions): Promise { + 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.toFetchRequestOptions) + const [body, standardHeaders] = toFetchBody( + standardRequest.body, + standardRequest.headers, + this.toFetchRequestOptions, + ) const init: RequestInit & { duplex?: ReturnType } = { body, @@ -100,7 +115,9 @@ export class FetchLinkTransport implements StandardLink } if (response.type === 'opaque') { - throw new TypeError('Unexpected opaque response: the response is unreadable, likely due to a no-cors request.') + throw new TypeError( + 'Unexpected opaque response: the response is unreadable, likely due to a no-cors request.', + ) } const standardResponse = toStandardLazyResponse(response) diff --git a/packages/client/src/adapters/message-port/index.test.ts b/packages/client/src/adapters/message-port/index.test.ts index fe981df20b..27067b6ced 100644 --- a/packages/client/src/adapters/message-port/index.test.ts +++ b/packages/client/src/adapters/message-port/index.test.ts @@ -1,3 +1,3 @@ it('exports RPCLink', async () => { - await expect(import('.')).resolves.toHaveProperty('RPCLink') + await expect(import('./index.ts')).resolves.toHaveProperty('RPCLink') }) diff --git a/packages/client/src/adapters/message-port/index.ts b/packages/client/src/adapters/message-port/index.ts index 32a17d0359..65bc1054c4 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 './message-port' -export * from './rpc-link' -export * from './transport' +export * from './message-port.ts' +export * from './rpc-link.ts' +export * from './transport.ts' 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 3b451a6a66..4568aba653 100644 --- a/packages/client/src/adapters/message-port/message-port.test.ts +++ b/packages/client/src/adapters/message-port/message-port.test.ts @@ -1,4 +1,4 @@ -import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from './message-port' +import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from './message-port.ts' describe('postMessagePortMessage', () => { it('posts message without transfer', () => { diff --git a/packages/client/src/adapters/message-port/message-port.ts b/packages/client/src/adapters/message-port/message-port.ts index eaa83bb350..9576a158d7 100644 --- a/packages/client/src/adapters/message-port/message-port.ts +++ b/packages/client/src/adapters/message-port/message-port.ts @@ -2,6 +2,7 @@ * The message port used by electron in main process */ export interface MessagePortMainLike { + // oxlint-disable-next-line typescript/no-unnecessary-type-parameters on: (event: T, callback: (event?: { data: any }) => void) => void postMessage: (data: any, transfer?: any[]) => void } @@ -19,7 +20,10 @@ export interface BrowserPortLike { postMessage: (data: any) => void } -export type SupportedMessagePort = Pick | MessagePortMainLike | 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) @@ -33,13 +37,15 @@ export function postMessagePortMessage( ): void { if (transfer) { port.postMessage(data, transfer) - } - else { + } else { port.postMessage(data) } } -export function onMessagePortMessage(port: SupportedMessagePort, callback: (data: SupportedMessagePortData) => void): void { +export function onMessagePortMessage( + port: SupportedMessagePort, + callback: (data: SupportedMessagePortData) => void, +): void { /** * MessagePort in node.js might have "on" method but it pass different parameters vs MessagePortMainLike * So we need check "addEventListener" before "on" @@ -48,18 +54,15 @@ export function onMessagePortMessage(port: SupportedMessagePort, callback: (data port.addEventListener('message', (event) => { callback(event.data) }) - } - else if ('on' in port) { + } else if ('on' in port) { port.on('message', (event) => { callback(event?.data) }) - } - else if ('onMessage' in port) { + } else if ('onMessage' in port) { port.onMessage.addListener((data) => { callback(data) }) - } - else { + } else { throw new Error('Cannot find a addEventListener/on/onMessage method on the port') } } @@ -73,18 +76,15 @@ export function onMessagePortClose(port: SupportedMessagePort, callback: () => v port.addEventListener('close', () => { callback() }) - } - else if ('on' in port) { + } else if ('on' in port) { port.on('close', () => { callback() }) - } - else if ('onDisconnect' in port) { + } else if ('onDisconnect' in port) { port.onDisconnect.addListener(() => { callback() }) - } - else { + } else { throw new Error('Cannot find a addEventListener/on/onDisconnect method on the port') } } 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 38f861e8a0..ad6abb4fea 100644 --- a/packages/client/src/adapters/message-port/rpc-link.test.ts +++ b/packages/client/src/adapters/message-port/rpc-link.test.ts @@ -1,7 +1,8 @@ import { AbortError, sleep } from '@orpc/shared' import { decodePeerMessage, encodePeerMessage } from '@standard-server/peer' -import { createORPCClient } from '../../client' -import { RPCLink } from './rpc-link' + +import { createORPCClient } from '../../client.ts' +import { RPCLink } from './rpc-link.ts' describe('rpcLink', () => { beforeEach(() => { @@ -14,10 +15,8 @@ describe('rpcLink', () => { const createPort = () => { const port = { addEventListener: vi.fn((event: string, callback: any) => { - if (event === 'message') - onMessage = callback - if (event === 'close') - onClose = callback + if (event === 'message') onMessage = callback + if (event === 'close') onClose = callback }), postMessage: vi.fn(), } @@ -30,18 +29,26 @@ describe('rpcLink', () => { 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) + }: { + 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 } + message: { id: string; kind: string; json: any } } } @@ -97,10 +104,12 @@ describe('rpcLink', () => { it('can encode messages with prefix', async () => { const port = createPort() - const orpc = createORPCClient(new RPCLink({ - port, - encodePeerMessage: { prefix: 'orpc:' }, - })) as any + const orpc = createORPCClient( + new RPCLink({ + port, + encodePeerMessage: { prefix: 'orpc:' }, + }), + ) as any const promise = expect(orpc.ping('input')).resolves.toEqual('pong') @@ -118,10 +127,12 @@ describe('rpcLink', () => { 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 + const orpc = createORPCClient( + new RPCLink({ + port, + decodePeerMessage: { prefix: 'orpc:' }, + }), + ) as any const promise = expect(orpc.ping('input')).resolves.toEqual('pong') @@ -143,10 +154,12 @@ describe('rpcLink', () => { 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 orpc = createORPCClient( + new RPCLink({ + port, + experimental_transfer: transfer, + }), + ) as any const promise = expect(orpc.ping('input')).resolves.toEqual('pong') @@ -154,10 +167,7 @@ describe('rpcLink', () => { const message = port.postMessage.mock.calls[0]![0] - expect(port.postMessage).toHaveBeenCalledWith( - message, - [transferable], - ) + expect(port.postMessage).toHaveBeenCalledWith(message, [transferable]) onMessage({ data: { diff --git a/packages/client/src/adapters/message-port/rpc-link.ts b/packages/client/src/adapters/message-port/rpc-link.ts index b891000a9d..6feab105f3 100644 --- a/packages/client/src/adapters/message-port/rpc-link.ts +++ b/packages/client/src/adapters/message-port/rpc-link.ts @@ -1,12 +1,11 @@ -import type { ClientContext } from '../../types' -import type { RPCLinkCodecOptions, StandardLinkOptions } from '../standard' -import type { MessagePortLinkTransportOptions } from './transport' -import { RPCLinkCodec, StandardLink } from '../standard' -import { MessagePortLinkTransport } from './transport' +import type { ClientContext } from '../../types.ts' +import type { RPCLinkCodecOptions, StandardLinkOptions } from '../standard/index.ts' +import { RPCLinkCodec, StandardLink } from '../standard/index.ts' +import type { MessagePortLinkTransportOptions } from './transport.ts' +import { MessagePortLinkTransport } from './transport.ts' export interface RPCLinkOptions - extends StandardLinkOptions, MessagePortLinkTransportOptions, RPCLinkCodecOptions { -} + extends StandardLinkOptions, MessagePortLinkTransportOptions, RPCLinkCodecOptions {} /** * Client link that communicates with an RPC Handler over a Message Port diff --git a/packages/client/src/adapters/message-port/transport.ts b/packages/client/src/adapters/message-port/transport.ts index d5db102fe0..dd492a28d2 100644 --- a/packages/client/src/adapters/message-port/transport.ts +++ b/packages/client/src/adapters/message-port/transport.ts @@ -1,14 +1,27 @@ import type { Promisable, Value } from '@orpc/shared' -import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { DecodePeerMessageOptions, EncodePeerMessageOptions, ServerPeerSendMessage } from '@standard-server/peer' -import type { ClientContext, ClientOptions } from '../../types' -import type { StandardLinkTransport } from '../standard' -import type { SupportedMessagePort } from './message-port' import { AbortError, value } from '@orpc/shared' -import { ClientPeer, decodePeerMessage, encodePeerMessage, isPeerMessage, isServerPeerSendMessage } from '@standard-server/peer' -import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from './message-port' - -type DecodedRequestMessage = ConstructorParameters[0] extends (message: infer TMessage) => unknown +import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' +import type { + DecodePeerMessageOptions, + EncodePeerMessageOptions, + ServerPeerSendMessage, +} from '@standard-server/peer' +import { + ClientPeer, + decodePeerMessage, + encodePeerMessage, + isPeerMessage, + isServerPeerSendMessage, +} from '@standard-server/peer' + +import type { ClientContext, ClientOptions } from '../../types.ts' +import type { StandardLinkTransport } from '../standard/index.ts' +import type { SupportedMessagePort } from './message-port.ts' +import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from './message-port.ts' + +type DecodedRequestMessage = ConstructorParameters[0] extends ( + message: infer TMessage, +) => unknown ? TMessage : never @@ -24,7 +37,12 @@ export interface MessagePortLinkTransportOptions<_T extends ClientContext> { * **Note**: Returning `null` or `undefined` disables this feature. * **Warning**: Make sure your message port supports `transfer` before using this feature. */ - experimental_transfer?: Value, [message: DecodedRequestMessage, port: SupportedMessagePort]> | undefined + experimental_transfer?: + | Value< + Promisable, + [message: DecodedRequestMessage, port: SupportedMessagePort] + > + | undefined /** * Options for encoding peer messages. such as `prefix` for distinguishing messages on the same channel.. @@ -40,7 +58,12 @@ export interface MessagePortLinkTransportOptions<_T extends ClientContext> { export class MessagePortLinkTransport implements StandardLinkTransport { private readonly peer: ClientPeer - constructor({ port, experimental_transfer, encodePeerMessage: encodePeerMessageOptions, decodePeerMessage: decodePeerMessageOptions }: MessagePortLinkTransportOptions) { + constructor({ + port, + experimental_transfer, + encodePeerMessage: encodePeerMessageOptions, + decodePeerMessage: decodePeerMessageOptions, + }: MessagePortLinkTransportOptions) { let closeReason: undefined | AbortError this.peer = new ClientPeer(async (message) => { @@ -53,8 +76,7 @@ export class MessagePortLinkTransport implements Standa if (transfer) { postMessagePortMessage(port, message, transfer) - } - else { + } else { postMessagePortMessage(port, await encodePeerMessage(message, encodePeerMessageOptions)) } }) @@ -64,13 +86,14 @@ export class MessagePortLinkTransport implements Standa if (typeof data === 'string' || data instanceof Uint8Array) { // MessagePort receives the exact payload sent, and `encodePeerMessage` only returns string or Uint8Array. - const result = decodePeerMessage(data as string | Uint8Array, decodePeerMessageOptions) + const result = decodePeerMessage( + data as string | Uint8Array, + decodePeerMessageOptions, + ) if (result.matched && isServerPeerSendMessage(result.message)) { peerMessage = result.message } - } - - else if (isPeerMessage(data) && isServerPeerSendMessage(data)) { + } else if (isPeerMessage(data) && isServerPeerSendMessage(data)) { peerMessage = data } @@ -92,7 +115,11 @@ export class MessagePortLinkTransport implements Standa }) } - async send(standardRequest: StandardRequest, _path: string[], _options: ClientOptions): Promise { + 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 index deefe7136c..d3f18ef84e 100644 --- a/packages/client/src/adapters/standard/codec.ts +++ b/packages/client/src/adapters/standard/codec.ts @@ -1,19 +1,22 @@ import type { Promisable } from '@orpc/shared' import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { ClientContext, ClientOptions } from '../../types' -export type StandardLinkCodecDecodedResponse = { kind: 'output', output: unknown } | { kind: 'error', error: Error } +import type { ClientContext, ClientOptions } from '../../types.ts' + +export type StandardLinkCodecDecodedResponse = + | { kind: 'output'; output: unknown } + | { kind: 'error'; error: Error } export interface StandardLinkCodec { encodeInput( input: unknown, path: string[], - options: ClientOptions + options: ClientOptions, ): Promisable decodeResponse( response: StandardLazyResponse, path: string[], - options: ClientOptions + options: ClientOptions, ): Promisable } diff --git a/packages/client/src/adapters/standard/index.test.ts b/packages/client/src/adapters/standard/index.test.ts index 97ea9309e0..7c9bafb7c7 100644 --- a/packages/client/src/adapters/standard/index.test.ts +++ b/packages/client/src/adapters/standard/index.test.ts @@ -1,5 +1,5 @@ it('exports StandardLink, RPCLinkCodec', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).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 b2a051e923..ef31e67d40 100644 --- a/packages/client/src/adapters/standard/index.ts +++ b/packages/client/src/adapters/standard/index.ts @@ -1,5 +1,5 @@ -export * from './codec' -export * from './link' -export type { StandardLinkPlugin } from './plugin' -export * from './rpc-link-codec' -export * from './transport' +export * from './codec.ts' +export * from './link.ts' +export type { StandardLinkPlugin } from './plugin.ts' +export * from './rpc-link-codec.ts' +export * from './transport.ts' diff --git a/packages/client/src/adapters/standard/link.test.ts b/packages/client/src/adapters/standard/link.test.ts index 03495d9fc6..91efc77270 100644 --- a/packages/client/src/adapters/standard/link.test.ts +++ b/packages/client/src/adapters/standard/link.test.ts @@ -1,9 +1,10 @@ -import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { StandardLinkCodec } from './codec' -import type { StandardLinkTransport } from './transport' import { getTracer, isAsyncIteratorObject, setTracer } from '@orpc/shared' -import { ORPCError } from '../../error' -import { StandardLink } from './link' +import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' + +import { ORPCError } from '../../error.ts' +import type { StandardLinkCodec } from './codec.ts' +import { StandardLink } from './link.ts' +import type { StandardLinkTransport } from './transport.ts' beforeEach(() => { vi.clearAllMocks() @@ -40,10 +41,19 @@ describe('standardLink', () => { return chunks } - function mockRoundTrip(codec: StandardLinkCodec, transport: StandardLinkTransport, output: unknown, headers: Record = {}): StandardRequest { + function mockRoundTrip( + codec: StandardLinkCodec, + transport: StandardLinkTransport, + output: unknown, + headers: Record = {}, + ): StandardRequest { const request: StandardRequest = { method: 'POST', url: '/test', headers, body: undefined } vi.mocked(codec.encodeInput).mockResolvedValueOnce(request) - vi.mocked(transport.send).mockResolvedValueOnce({ status: 200, headers: {}, resolveBody: () => Promise.resolve(undefined) }) + vi.mocked(transport.send).mockResolvedValueOnce({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve(undefined), + }) vi.mocked(codec.decodeResponse).mockResolvedValueOnce({ kind: 'output', output }) return request } @@ -82,30 +92,34 @@ describe('standardLink', () => { const signal = AbortSignal.timeout(100) const lastEventId = '__lastEventId__' - const output = await link.call(['planet', 'create'], { name: 'Earth' }, { context, signal, lastEventId }) + const output = await link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context, signal, lastEventId }, + ) expect(output).toEqual('__output__') expect(codec.encodeInput).toHaveBeenCalledTimes(1) - expect(codec.encodeInput).toHaveBeenCalledWith( - { name: 'Earth' }, - ['planet', 'create'], - { context, signal, lastEventId }, - ) + 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(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(codec.decodeResponse).toHaveBeenCalledWith(__standardResponse, ['planet', 'create'], { + context, + signal, + lastEventId, + }) expect(interceptor).toHaveBeenCalledTimes(1) expect(interceptor).toHaveBeenCalledWith({ @@ -198,7 +212,7 @@ describe('standardLink', () => { mockRoundTrip(codec, transport, output) - const tracedOutput = await link.call(['test'], input, { context: {} }) as ReadableStream + const tracedOutput = (await link.call(['test'], input, { context: {} })) as ReadableStream const passedInput = vi.mocked(codec.encodeInput).mock.calls[0]![0] as ReadableStream expect(passedInput).not.toBe(input) // should be a wrapped version of the original input @@ -210,7 +224,9 @@ describe('standardLink', () => { await expect(readAll(tracedOutput)).resolves.toEqual(['out']) }) - it('passes AsyncIteratorObject and ReadableStream through untouched without a tracer', async ({ onTestFinished }) => { + it('passes AsyncIteratorObject and ReadableStream through untouched without a tracer', async ({ + onTestFinished, + }) => { const tracer = getTracer() setTracer(undefined) onTestFinished(() => setTracer(tracer)) @@ -219,7 +235,10 @@ describe('standardLink', () => { yield 1 } - for (const [input, output] of [[gen(), gen()], [makeStream('in'), makeStream('out')]]) { + for (const [input, output] of [ + [gen(), gen()], + [makeStream('in'), makeStream('out')], + ]) { const codec = makeCodec() const transport = makeTransport() const link = new StandardLink(codec, transport) @@ -231,13 +250,26 @@ describe('standardLink', () => { } }) - it('injects the trace context of the call into the request headers', async ({ onTestFinished }) => { + it('injects the trace context of the call into the request headers', async ({ + onTestFinished, + }) => { const tracer = getTracer() - const span = { setAttribute: vi.fn(), updateName: vi.fn(), addEvent: vi.fn(), recordException: vi.fn(), end: vi.fn() } + const span = { + setAttribute: vi.fn(), + updateName: vi.fn(), + addEvent: vi.fn(), + recordException: vi.fn(), + end: vi.fn(), + } const inject = vi.fn((_span: unknown, headers: Record) => { headers.traceparent = '00-test' }) - setTracer({ getActiveSpan: () => undefined, startActiveSpan: (_name: string, _parent: unknown, fn: (span: unknown) => unknown) => fn(span), inject } as any) + setTracer({ + getActiveSpan: () => undefined, + startActiveSpan: (_name: string, _parent: unknown, fn: (span: unknown) => unknown) => + fn(span), + inject, + } as any) onTestFinished(() => setTracer(tracer)) const codec = makeCodec() @@ -249,14 +281,27 @@ describe('standardLink', () => { expect(inject).toHaveBeenCalledTimes(1) expect(inject).toHaveBeenCalledWith(span, { 'x-custom': 'value', 'traceparent': '00-test' }) - expect(vi.mocked(transport.send).mock.calls[0]![0].headers).toEqual({ 'x-custom': 'value', 'traceparent': '00-test' }) + expect(vi.mocked(transport.send).mock.calls[0]![0].headers).toEqual({ + 'x-custom': 'value', + 'traceparent': '00-test', + }) expect(request.headers).toEqual({ 'x-custom': 'value' }) // the encoded request is not mutated }) it('sends the request as is when the tracer does not propagate', async ({ onTestFinished }) => { const tracer = getTracer() - const span = { setAttribute: vi.fn(), updateName: vi.fn(), addEvent: vi.fn(), recordException: vi.fn(), end: vi.fn() } - setTracer({ getActiveSpan: () => undefined, startActiveSpan: (_name: string, _parent: unknown, fn: (span: unknown) => unknown) => fn(span) } as any) + const span = { + setAttribute: vi.fn(), + updateName: vi.fn(), + addEvent: vi.fn(), + recordException: vi.fn(), + end: vi.fn(), + } + setTracer({ + getActiveSpan: () => undefined, + startActiveSpan: (_name: string, _parent: unknown, fn: (span: unknown) => unknown) => + fn(span), + } as any) onTestFinished(() => setTracer(tracer)) const codec = makeCodec() @@ -274,7 +319,9 @@ describe('standardLink', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [{ name: 'test-plugin', init: () => ({ interceptors: [async () => '__INTERCEPTED__'] }) }], + plugins: [ + { name: 'test-plugin', init: () => ({ interceptors: [async () => '__INTERCEPTED__'] }) }, + ], }) 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 2d83361a6d..459ffae050 100644 --- a/packages/client/src/adapters/standard/link.ts +++ b/packages/client/src/adapters/standard/link.ts @@ -1,23 +1,41 @@ import type { Interceptor } from '@orpc/shared' +import { + getTracer, + intercept, + isAsyncIteratorObject, + ORPC_NAME, + override, + runWithSpan, + traceAsyncIterator, + traceReadableStream, +} from '@orpc/shared' import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { ClientContext, ClientLink, ClientOptions } from '../../types' -import type { StandardLinkCodec } from './codec' -import type { StandardLinkPlugin } from './plugin' -import type { StandardLinkTransport } from './transport' -import { getTracer, intercept, isAsyncIteratorObject, ORPC_NAME, override, runWithSpan, traceAsyncIterator, traceReadableStream } from '@orpc/shared' -import { CompositeStandardLinkPlugin } from './plugin' + +import type { ClientContext, ClientLink, ClientOptions } from '../../types.ts' +import type { StandardLinkCodec } from './codec.ts' +import type { StandardLinkPlugin } from './plugin.ts' +import { CompositeStandardLinkPlugin } from './plugin.ts' +import type { StandardLinkTransport } from './transport.ts' export interface StandardLinkInterceptorOptions extends ClientOptions { path: string[] input: unknown } -export type StandardLinkInterceptor = Interceptor, Promise> - -export interface StandardLinkTransportInterceptorOptions extends ClientOptions { +export type StandardLinkInterceptor = Interceptor< + StandardLinkInterceptorOptions, + Promise +> + +export interface StandardLinkTransportInterceptorOptions< + T extends ClientContext, +> extends ClientOptions { path: string[] request: StandardRequest } -export type StandardLinkTransportInterceptor = Interceptor, Promise> +export type StandardLinkTransportInterceptor = Interceptor< + StandardLinkTransportInterceptorOptions, + Promise +> export interface StandardLinkOptions { /** @@ -67,9 +85,7 @@ export class StandardLink implements ClientLink { * Remember use `override` for AsyncIteratorObject to remain other special properties */ input = override(input, traceAsyncIterator('consume_async_iterator_object_input', input)) - } - - else if (getTracer() && input instanceof ReadableStream) { + } else if (getTracer() && input instanceof ReadableStream) { /** * @warning * Remember use `override` for ReadableStream to remain other special properties @@ -77,73 +93,76 @@ export class StandardLink implements ClientLink { input = override(input, traceReadableStream('consume_octet_stream_input', input)) } - 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 should manually pass the parent span here. - */ - const tracer = getTracer() - const activeSpan = tracer?.getActiveSpan() ?? span - - let request = await runWithSpan( - { name: 'encode_input', parent: activeSpan }, - () => this.codec.encodeInput(input, path, options), - ) - - if (activeSpan && tracer?.inject) { - const headers = { ...request.headers } - tracer.inject(activeSpan, headers) - request = { ...request, headers } - } - - const response = await intercept( - this.transportInterceptors, - { ...options, path, request }, - ({ path, request, ...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 should manually pass the parent span here. + */ + const tracer = getTracer() + const activeSpan = tracer?.getActiveSpan() ?? span + + let request = await runWithSpan({ name: 'encode_input', parent: activeSpan }, () => + this.codec.encodeInput(input, path, options), + ) + + if (activeSpan && tracer?.inject) { + const headers = { ...request.headers } + tracer.inject(activeSpan, headers) + request = { ...request, headers } + } + + const response = await intercept( + this.transportInterceptors, + { ...options, path, request }, + ({ path, request, ...options }) => { + /** + * In browsers, the OpenTelemetry context manager may not work reliably with async functions, + * so we should manually pass the parent span here. + */ + const activeTransportSpan = tracer?.getActiveSpan() ?? activeSpan + + return runWithSpan({ name: 'send_request', parent: activeTransportSpan }, () => + this.transport.send(request, path, options), + ) + }, + ) + + const decodedResult = await runWithSpan( + { name: 'decode_response', parent: activeSpan }, + () => this.codec.decodeResponse(response, path, options), + ) + + if (decodedResult.kind === 'error') { + throw decodedResult.error + } + + const output = decodedResult.output + + if (getTracer() && isAsyncIteratorObject(output)) { /** - * In browsers, the OpenTelemetry context manager may not work reliably with async functions, - * so we should manually pass the parent span here. + * Do not pass the active span as parent here, as it is a lazy span. + * + * @warning + * Remember use `override` for AsyncIteratorObject to remain other special properties */ - const activeTransportSpan = tracer?.getActiveSpan() ?? activeSpan - - return runWithSpan( - { name: 'send_request', parent: activeTransportSpan }, - () => this.transport.send(request, path, options), + return override( + output, + traceAsyncIterator('consume_async_iterator_object_output', output), ) - }, - ) - - const decodedResult = await runWithSpan( - { name: 'decode_response', parent: activeSpan }, - () => this.codec.decodeResponse(response, path, options), - ) - - if (decodedResult.kind === 'error') { - throw decodedResult.error - } - - const output = decodedResult.output - - if (getTracer() && isAsyncIteratorObject(output)) { - /** - * Do not pass the active span as parent here, as it is a lazy span. - * - * @warning - * Remember use `override` for AsyncIteratorObject to remain other special properties - */ - return override(output, traceAsyncIterator('consume_async_iterator_object_output', output)) - } - - else if (getTracer() && output instanceof ReadableStream) { - /** - * @warning - * Remember use `override` for ReadableStream to remain other special properties - */ - return override(output, traceReadableStream('consume_octet_stream_output', output)) - } + } else if (getTracer() && output instanceof ReadableStream) { + /** + * @warning + * Remember use `override` for ReadableStream to remain other special properties + */ + return override(output, traceReadableStream('consume_octet_stream_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 cddd9cb002..d477240951 100644 --- a/packages/client/src/adapters/standard/plugin.test.ts +++ b/packages/client/src/adapters/standard/plugin.test.ts @@ -1,5 +1,5 @@ -import type { StandardLinkPlugin } from './plugin' -import { CompositeStandardLinkPlugin } from './plugin' +import type { StandardLinkPlugin } from './plugin.ts' +import { CompositeStandardLinkPlugin } from './plugin.ts' describe('compositeStandardLinkPlugin', () => { beforeEach(() => { diff --git a/packages/client/src/adapters/standard/plugin.ts b/packages/client/src/adapters/standard/plugin.ts index f816b9a721..8b976f49fe 100644 --- a/packages/client/src/adapters/standard/plugin.ts +++ b/packages/client/src/adapters/standard/plugin.ts @@ -1,8 +1,9 @@ import type { OrderablePlugin } from '@orpc/shared' -import type { ClientContext } from '../../types' -import type { StandardLinkOptions } from './link' import { sortPlugins } from '@orpc/shared' +import type { ClientContext } from '../../types.ts' +import type { StandardLinkOptions } from './link.ts' + export interface StandardLinkPlugin extends OrderablePlugin { /** * Initializes the plugin and returns new link options. 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 1de73f6298..0f514dd036 100644 --- a/packages/client/src/adapters/standard/rpc-link-codec.test.ts +++ b/packages/client/src/adapters/standard/rpc-link-codec.test.ts @@ -1,7 +1,8 @@ import type { StandardUrl } from '@standard-server/core' -import { MalformedResponseError, ORPCError, ResponseError } from '../../error' -import { RPCSerializer } from '../../rpc-serializer' -import { RPCLinkCodec } from './rpc-link-codec' + +import { MalformedResponseError, ORPCError, ResponseError } from '../../error.ts' +import { RPCSerializer } from '../../rpc-serializer.ts' +import { RPCLinkCodec } from './rpc-link-codec.ts' beforeEach(() => { vi.clearAllMocks() @@ -120,14 +121,23 @@ describe('rpcLinkCodec', () => { }) it.each([ - ['FormData', () => { - const f = new FormData() - f.set('k', 'v') - return f - }], + [ + 'FormData', + () => { + const f = new FormData() + f.set('k', 'v') + return f + }, + ], ['Blob', () => new Blob(['data'])], ['ReadableStream', () => new ReadableStream()], - ['AsyncIteratorObject', () => (async function* () { yield 1 })()], + [ + 'AsyncIteratorObject', + () => + (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() @@ -288,7 +298,10 @@ describe('rpcLinkCodec', () => { }) it('decodes ORPCError JSON from error response', async () => { - const error = new ORPCError('NOT_FOUND', { message: 'Resource not found', data: { id: '123' } }) + const error = new ORPCError('NOT_FOUND', { + message: 'Resource not found', + data: { id: '123' }, + }) const serialized = serializer.serialize(error.toJSON()) const result: any = await codec.decodeResponse({ @@ -299,36 +312,58 @@ describe('rpcLinkCodec', () => { expect(result).toEqual({ kind: 'error', - error: expect.objectContaining({ code: 'NOT_FOUND', message: 'Resource not found', data: { id: '123' } }), + error: expect.objectContaining({ + code: 'NOT_FOUND', + message: 'Resource not found', + data: { id: '123' }, + }), }) expect(result.error).toBeInstanceOf(ORPCError) expect(result.error.cause).toBeInstanceOf(ResponseError) expect(result.error.cause).not.toBeInstanceOf(MalformedResponseError) expect(result.error.cause.message).toBe('Received a 404 response.') - expect(result.error.cause.response).toEqual({ status: 404, headers: { 'x-request-id': 'abc' }, body: serialized }) + expect(result.error.cause.response).toEqual({ + status: 404, + headers: { 'x-request-id': 'abc' }, + body: serialized, + }) }) it('throws MalformedResponseError on non-ORPCError error responses', async () => { const serialized = serializer.serialize({ something: 'unexpected' }) - const error: any = await codec.decodeResponse({ - status: 403, - headers: { 'x-header': 'value' }, - resolveBody: () => Promise.resolve(serialized), - }).then(() => null, e => e) + const error: any = await codec + .decodeResponse({ + status: 403, + headers: { 'x-header': 'value' }, + resolveBody: () => Promise.resolve(serialized), + }) + .then( + () => null, + (e) => e, + ) expect(error).toBeInstanceOf(MalformedResponseError) expect(error.message).toBe('Forbidden') - expect(error.response).toEqual({ status: 403, headers: { 'x-header': 'value' }, body: serialized }) + expect(error.response).toEqual({ + status: 403, + headers: { 'x-header': 'value' }, + body: serialized, + }) }) it('throws MalformedResponseError on invalid RPC response format', async () => { - const error: any = await codec.decodeResponse({ - status: 200, - headers: {}, - resolveBody: () => Promise.resolve({ meta: 123 }), - }).then(() => null, e => e) + const error: any = await codec + .decodeResponse({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve({ meta: 123 }), + }) + .then( + () => null, + (e) => e, + ) expect(error).toBeInstanceOf(MalformedResponseError) expect(error.message).toBe('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 849635c463..3a42272772 100644 --- a/packages/client/src/adapters/standard/rpc-link-codec.ts +++ b/packages/client/src/adapters/standard/rpc-link-codec.ts @@ -1,13 +1,19 @@ import type { Promisable, Public, Value } from '@orpc/shared' -import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardUrl } from '@standard-server/core' -import type { ClientContext, ClientOptions } from '../../types' -import type { StandardLinkCodec, StandardLinkCodecDecodedResponse } from '../standard' import { isAsyncIteratorObject, pathToHttpPath, stringifyJSON, value } from '@orpc/shared' +import type { + StandardHeaders, + StandardLazyResponse, + StandardRequest, + StandardUrl, +} from '@standard-server/core' import { mergeStandardHeaders, parseStandardUrl } from '@standard-server/core' import { toStandardHeaders } from '@standard-server/fetch' -import { MalformedResponseError, ResponseError } from '../../error' -import { createORPCErrorFromJson, isORPCErrorJson } from '../../error-utils' -import { RPCSerializer } from '../../rpc-serializer' + +import { createORPCErrorFromJson, isORPCErrorJson } from '../../error-utils.ts' +import { MalformedResponseError, ResponseError } from '../../error.ts' +import { RPCSerializer } from '../../rpc-serializer.ts' +import type { ClientContext, ClientOptions } from '../../types.ts' +import type { StandardLinkCodec, StandardLinkCodecDecodedResponse } from '../standard/index.ts' export interface RPCLinkCodecOptions { /** @@ -17,7 +23,9 @@ export interface RPCLinkCodecOptions { * * @default '/' */ - url?: Value, [options: ClientOptions, path: string[], input: unknown]> | undefined + url?: + | Value, [options: ClientOptions, path: string[], input: unknown]> + | undefined /** * The maximum length of the URL. @@ -26,14 +34,21 @@ export interface RPCLinkCodecOptions { * * @default 2083 */ - maxUrlLength?: Value, [options: ClientOptions, path: string[], input: unknown]> | undefined + maxUrlLength?: + | Value, [options: ClientOptions, path: string[], input: unknown]> + | undefined /** * The method used to make the request. * * @default 'POST' */ - method?: Value, [options: ClientOptions, path: string[], input: unknown]> | undefined + method?: + | Value< + Promisable<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'QUERY'>, + [options: ClientOptions, path: string[], input: unknown] + > + | undefined /** * The method to use when the payload cannot safely pass to the server with method return from method function. @@ -46,7 +61,12 @@ export interface RPCLinkCodecOptions { /** * Inject headers to the request. */ - headers?: Value, [options: ClientOptions, path: string[], input: unknown]> | undefined + headers?: + | Value< + Promisable, + [options: ClientOptions, path: string[], input: unknown] + > + | undefined /** * Override the default RPC serializer. @@ -64,9 +84,7 @@ export class RPCLinkCodec implements StandardLinkCodec< private readonly headers: Exclude['headers'], undefined> private readonly serializer: Exclude['serializer'], undefined> - constructor( - options: RPCLinkCodecOptions, - ) { + constructor(options: RPCLinkCodecOptions) { this.baseUrl = options.url ?? '/' this.maxUrlLength = options.maxUrlLength ?? 2083 this.fallbackMethod = options.fallbackMethod ?? 'POST' @@ -75,7 +93,11 @@ export class RPCLinkCodec implements StandardLinkCodec< this.serializer = options.serializer ?? new RPCSerializer() } - async encodeInput(input: unknown, path: string[], options: ClientOptions): Promise { + 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 }) @@ -85,15 +107,16 @@ export class RPCLinkCodec implements StandardLinkCodec< const baseUrl = await value(this.baseUrl, options, path, input) const [pathname, search, hash] = parseStandardUrl(baseUrl) - const newPathname = `${pathname.replace(END_SLASH_REGEX, '')}${pathToHttpPath(path)}` as StandardUrl + 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) + expectedMethod === 'GET' && + !(serialized instanceof Blob) && + !(serialized instanceof ReadableStream) && + !(serialized instanceof FormData) && + !isAsyncIteratorObject(serialized) ) { const maxUrlLength = await value(this.maxUrlLength, options, path, input) const mergedSearch = new URLSearchParams(search) @@ -130,8 +153,7 @@ export class RPCLinkCodec implements StandardLinkCodec< const deserialized = await (async () => { try { return this.serializer.deserialize(body) - } - catch (cause) { + } catch (cause) { throw new MalformedResponseError({ message: 'Invalid RPC response format.', response: { status: response.status, headers: response.headers, body }, @@ -145,12 +167,16 @@ export class RPCLinkCodec implements StandardLinkCodec< return { kind: 'error', error: createORPCErrorFromJson(deserialized, { - cause: new ResponseError({ response: { status: response.status, headers: response.headers, body } }), + cause: new ResponseError({ + response: { status: response.status, headers: response.headers, body }, + }), }), } } - throw new MalformedResponseError({ response: { headers: response.headers, status: response.status, body } }) + throw new MalformedResponseError({ + response: { headers: response.headers, status: response.status, body }, + }) } return { kind: 'output', output: deserialized } diff --git a/packages/client/src/adapters/standard/transport.ts b/packages/client/src/adapters/standard/transport.ts index d97e9a6e73..c052b9bfba 100644 --- a/packages/client/src/adapters/standard/transport.ts +++ b/packages/client/src/adapters/standard/transport.ts @@ -1,5 +1,6 @@ import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { ClientContext, ClientOptions } from '../../types' + +import type { ClientContext, ClientOptions } from '../../types.ts' /** * Handles the transport layer for sending requests and receiving responses. @@ -11,5 +12,9 @@ export interface StandardLinkTransport { /** * @throws Transport-level errors (network failures, timeouts, etc.) */ - send(request: StandardRequest, path: string[], options: ClientOptions): Promise + send( + request: StandardRequest, + path: string[], + options: ClientOptions, + ): Promise } diff --git a/packages/client/src/adapters/websocket/index.test.ts b/packages/client/src/adapters/websocket/index.test.ts index fe981df20b..27067b6ced 100644 --- a/packages/client/src/adapters/websocket/index.test.ts +++ b/packages/client/src/adapters/websocket/index.test.ts @@ -1,3 +1,3 @@ it('exports RPCLink', async () => { - await expect(import('.')).resolves.toHaveProperty('RPCLink') + await expect(import('./index.ts')).resolves.toHaveProperty('RPCLink') }) diff --git a/packages/client/src/adapters/websocket/index.ts b/packages/client/src/adapters/websocket/index.ts index 9a7dc93c19..478aa2ad4b 100644 --- a/packages/client/src/adapters/websocket/index.ts +++ b/packages/client/src/adapters/websocket/index.ts @@ -1,2 +1,2 @@ -export * from './rpc-link' -export * from './transport' +export * from './rpc-link.ts' +export * from './transport.ts' diff --git a/packages/client/src/adapters/websocket/rpc-link.test.ts b/packages/client/src/adapters/websocket/rpc-link.test.ts index ac9d68b3f7..74fdf94358 100644 --- a/packages/client/src/adapters/websocket/rpc-link.test.ts +++ b/packages/client/src/adapters/websocket/rpc-link.test.ts @@ -1,10 +1,12 @@ import type { IncomingMessage } from 'node:http' import type { AddressInfo } from 'node:net' + import { AbortError, promiseWithResolvers } from '@orpc/shared' import { decodePeerMessage, encodePeerMessage } from '@standard-server/peer' import { WebSocketServer, WebSocket as WsWebSocket } from 'ws' -import { createORPCClient } from '../../client' -import { RPCLink } from './rpc-link' + +import { createORPCClient } from '../../client.ts' +import { RPCLink } from './rpc-link.ts' beforeEach(() => { vi.clearAllMocks() @@ -22,7 +24,9 @@ describe('rpcLink', () => { 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 closeListeners = new Set< + (event: { code: number; reason: string }) => void | Promise + >() const websocket = { readyState: readyState as any, @@ -69,18 +73,22 @@ describe('rpcLink', () => { send: vi.fn(), async open() { websocket.readyState = WEBSOCKET_OPEN - await Promise.all([...openListeners].map(listener => listener())) + await Promise.all([...openListeners].map((listener) => listener())) }, async receive(data: unknown) { - await Promise.all([...messageListeners].map(listener => listener({ data }))) + await Promise.all([...messageListeners].map((listener) => listener({ data }))) }, - async close(event: Partial<{ code: number, reason: string }> = {}) { + 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 ?? '', - }))) + await Promise.all( + [...closeListeners].map((listener) => + listener({ + code: event.code ?? 1006, + reason: event.reason ?? '', + }), + ), + ) }, } @@ -92,18 +100,26 @@ describe('rpcLink', () => { 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) + }: { + 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 } + message: { id: string; kind: string; json: any } } } @@ -193,8 +209,12 @@ describe('rpcLink', () => { 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 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') @@ -238,11 +258,13 @@ describe('rpcLink', () => { it('supports prefixed peer messages and ignores unrelated frames', async () => { const ws = createWs() - const orpc = createORPCClient(new RPCLink({ - connect: () => ws, - encodePeerMessage: { prefix: 'orpc:' }, - decodePeerMessage: { prefix: 'orpc:' }, - })) as any + const orpc = createORPCClient( + new RPCLink({ + connect: () => ws, + encodePeerMessage: { prefix: 'orpc:' }, + decodePeerMessage: { prefix: 'orpc:' }, + }), + ) as any const promise = orpc.ping('input') @@ -281,7 +303,9 @@ describe('rpcLink', () => { await ws.close({ code: 4001, reason: 'server restart' }) - await expect(orpc.ping('second')).rejects.toThrow(new AbortError('WebSocket closed (code 4001: server restart)')) + await expect(orpc.ping('second')).rejects.toThrow( + new AbortError('WebSocket closed (code 4001: server restart)'), + ) expect(ws.send).toHaveBeenCalledTimes(1) expect(connect).toHaveBeenCalledTimes(1) }) @@ -292,7 +316,9 @@ describe('rpcLink', () => { const promise = orpc.ping('input') - await vi.waitFor(() => expect(ws.addEventListener).toHaveBeenCalledWith('close', expect.any(Function))) + await vi.waitFor(() => + expect(ws.addEventListener).toHaveBeenCalledWith('close', expect.any(Function)), + ) await ws.close() await expect(promise).rejects.toThrow(new AbortError('WebSocket closed (code 1006: )')) @@ -302,27 +328,37 @@ describe('rpcLink', () => { it.each([ ['closing', WEBSOCKET_CLOSING], ['closed', WEBSOCKET_CLOSED], - ] as const)('rejects calls when connect returns an already %s socket and reconnect is disabled', async (_, readyState) => { - const ws = createWs(readyState) - const connect = vi.fn(() => ws) - const orpc = createORPCClient(new RPCLink({ connect })) as any - - await expect(orpc.ping('first')).rejects.toThrow(new AbortError('WebSocket is already closing or closed')) - await expect(orpc.ping('second')).rejects.toThrow(new AbortError('WebSocket is already closing or closed')) - expect(ws.send).toHaveBeenCalledTimes(0) - expect(connect).toHaveBeenCalledTimes(1) - }) + ] as const)( + 'rejects calls when connect returns an already %s socket and reconnect is disabled', + async (_, readyState) => { + const ws = createWs(readyState) + const connect = vi.fn(() => ws) + const orpc = createORPCClient(new RPCLink({ connect })) as any + + await expect(orpc.ping('first')).rejects.toThrow( + new AbortError('WebSocket is already closing or closed'), + ) + await expect(orpc.ping('second')).rejects.toThrow( + new AbortError('WebSocket is already closing or closed'), + ) + expect(ws.send).toHaveBeenCalledTimes(0) + expect(connect).toHaveBeenCalledTimes(1) + }, + ) it('reconnects when connect returns an already closed socket', async () => { const closedSocket = createWs(WEBSOCKET_CLOSED) const openSocket = createWs() - const connect = vi.fn() + const connect = vi + .fn() .mockImplementationOnce(() => closedSocket) .mockImplementationOnce(() => openSocket) - const orpc = createORPCClient(new RPCLink({ - connect, - reconnect: { enabled: true, delay: () => 0 }, - })) as any + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { enabled: true, delay: () => 0 }, + }), + ) as any const promise = orpc.ping('input') @@ -340,108 +376,142 @@ describe('rpcLink', () => { 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 + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { + enabled: true, + delay, + maxAttempt: 1, + }, + }), + ) as any - await expect(orpc.ping('input')).rejects.toThrow('WebSocket reconnect failed after 1 attempt(s)') + 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) }) it('fails every call waiting on a reconnect cycle that gives up', async () => { const connect = vi.fn(() => Promise.reject(new Error('temporary outage'))) - const orpc = createORPCClient(new RPCLink({ - connect, - reconnect: { enabled: true, delay: () => 0, maxAttempt: 3 }, - })) as any - - await Promise.all(['first', 'second', 'third'].map(input => expect(orpc.ping(input)).rejects.toMatchObject({ - message: 'WebSocket reconnect failed after 3 attempt(s)', - cause: { message: 'temporary outage' }, - }))) + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { enabled: true, delay: () => 0, maxAttempt: 3 }, + }), + ) as any + + await Promise.all( + ['first', 'second', 'third'].map((input) => + expect(orpc.ping(input)).rejects.toMatchObject({ + message: 'WebSocket reconnect failed after 3 attempt(s)', + cause: { message: 'temporary outage' }, + }), + ), + ) expect(connect).toHaveBeenCalledTimes(3) }) it.each([ ['connect throws', () => Promise.reject(new Error('temporary outage')), 'temporary outage'], - ['socket closes before opening', () => { - const ws = createWs(WEBSOCKET_CONNECTING) - setTimeout(() => ws.close()) - return ws - }, 'WebSocket closed (code 1006: )'], - ])('starts a new reconnect cycle on the next call after giving up (%s)', async (_, fail, cause) => { - const recoveredSocket = createWs() - const connect = vi.fn() - .mockImplementationOnce(fail) - .mockImplementationOnce(fail) - .mockImplementationOnce(() => recoveredSocket) - const orpc = createORPCClient(new RPCLink({ - connect, - reconnect: { enabled: true, delay: () => 0, maxAttempt: 2 }, - })) as any - - await expect(orpc.ping('first')).rejects.toMatchObject({ - message: 'WebSocket reconnect failed after 2 attempt(s)', - cause: { message: cause }, - }) - expect(connect).toHaveBeenCalledTimes(2) + [ + 'socket closes before opening', + () => { + const ws = createWs(WEBSOCKET_CONNECTING) + setTimeout(() => ws.close()) + return ws + }, + 'WebSocket closed (code 1006: )', + ], + ])( + 'starts a new reconnect cycle on the next call after giving up (%s)', + async (_, fail, cause) => { + const recoveredSocket = createWs() + const connect = vi + .fn() + .mockImplementationOnce(fail) + .mockImplementationOnce(fail) + .mockImplementationOnce(() => recoveredSocket) + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { enabled: true, delay: () => 0, maxAttempt: 2 }, + }), + ) as any + + await expect(orpc.ping('first')).rejects.toMatchObject({ + message: 'WebSocket reconnect failed after 2 attempt(s)', + cause: { message: cause }, + }) + expect(connect).toHaveBeenCalledTimes(2) - const secondCall = orpc.ping('second') + const secondCall = orpc.ping('second') - await vi.waitFor(() => expect(recoveredSocket.send).toHaveBeenCalledTimes(1)) - expect(connect).toHaveBeenNthCalledWith(3, { totalAttempt: 3, attempt: 1 }) + await vi.waitFor(() => expect(recoveredSocket.send).toHaveBeenCalledTimes(1)) + expect(connect).toHaveBeenNthCalledWith(3, { totalAttempt: 3, attempt: 1 }) - const request = getSentRequest(recoveredSocket) - await recoveredSocket.receive(await createResponseMessage({ id: request.message.id, body: { json: 'recovered' } })) - await expect(secondCall).resolves.toEqual('recovered') - }) + const request = getSentRequest(recoveredSocket) + await recoveredSocket.receive( + await createResponseMessage({ id: request.message.id, body: { json: 'recovered' } }), + ) + await expect(secondCall).resolves.toEqual('recovered') + }, + ) it('stops connecting once maxTotalAttempt is reached, successful attempts included', async () => { const firstSocket = createWs() - const connect = vi.fn() + const connect = vi + .fn() .mockImplementationOnce(() => firstSocket) .mockRejectedValue(new Error('temporary outage')) - const orpc = createORPCClient(new RPCLink({ - connect, - reconnect: { enabled: true, delay: () => 0, maxTotalAttempt: 3 }, - })) as any + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { enabled: true, delay: () => 0, maxTotalAttempt: 3 }, + }), + ) as any const firstCall = orpc.ping('first') await vi.waitFor(() => expect(firstSocket.send).toHaveBeenCalledTimes(1)) - await firstSocket.receive(await createResponseMessage({ id: getSentRequest(firstSocket).message.id })) + await firstSocket.receive( + await createResponseMessage({ id: getSentRequest(firstSocket).message.id }), + ) await expect(firstCall).resolves.toEqual('pong') await firstSocket.close() const error = new AbortError('WebSocket reconnect stopped after 3 total attempt(s)') - await Promise.all(['second', 'third'].map(input => expect(orpc.ping(input)).rejects.toThrow(error))) + await Promise.all( + ['second', 'third'].map((input) => expect(orpc.ping(input)).rejects.toThrow(error)), + ) expect(connect).toHaveBeenCalledTimes(3) await expect(orpc.ping('fourth')).rejects.toThrow(error) expect(connect).toHaveBeenCalledTimes(3) }) - it('uses the default reconnect backoff before retrying a transient connection failure', async ({ onTestFinished }) => { + it('uses the default reconnect backoff before retrying a transient connection failure', async ({ + onTestFinished, + }) => { vi.useFakeTimers() onTestFinished(() => { vi.useRealTimers() }) const recoveredSocket = createWs() - const connect = vi.fn() + const connect = vi + .fn() .mockRejectedValueOnce(new Error('temporary outage')) .mockResolvedValueOnce(recoveredSocket) - const orpc = createORPCClient(new RPCLink({ - connect, - reconnect: { enabled: true }, - })) as any + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { enabled: true }, + }), + ) as any const promise = orpc.ping('input') @@ -456,7 +526,9 @@ describe('rpcLink', () => { expect(recoveredSocket.send).toHaveBeenCalledTimes(1) const request = getSentRequest(recoveredSocket) - await recoveredSocket.receive(await createResponseMessage({ id: request.message.id, body: { json: 'recovered' } })) + await recoveredSocket.receive( + await createResponseMessage({ id: request.message.id, body: { json: 'recovered' } }), + ) await expect(promise).resolves.toEqual('recovered') }) @@ -464,19 +536,24 @@ describe('rpcLink', () => { it('reconnects on the next call after a socket closes', async () => { const firstSocket = createWs() const secondSocket = createWs() - const connect = vi.fn() + const connect = vi + .fn() .mockImplementationOnce(() => firstSocket) .mockImplementationOnce(() => secondSocket) - const orpc = createORPCClient(new RPCLink({ - connect, - reconnect: { enabled: true }, - })) as any + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { enabled: true }, + }), + ) 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, body: { json: 'pong-1' } })) + await firstSocket.receive( + await createResponseMessage({ id: firstRequest.message.id, body: { json: 'pong-1' } }), + ) await expect(firstCall).resolves.toEqual('pong-1') await firstSocket.close({ code: 4001, reason: 'server restart' }) @@ -489,26 +566,31 @@ describe('rpcLink', () => { 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 secondSocket.receive( + await createResponseMessage({ id: secondRequest.message.id, body: { json: 'pong-2' } }), + ) await expect(secondCall).resolves.toEqual('pong-2') }) it('can proactively reconnect on close before the next call arrives', async () => { const firstSocket = createWs() const secondSocket = createWs() - const connect = vi.fn() + const connect = vi + .fn() .mockImplementationOnce(() => firstSocket) .mockImplementationOnce(() => secondSocket) - const orpc = createORPCClient(new RPCLink({ - connect, - reconnect: { - enabled: true, - onClose: { + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { enabled: true, - delay: 0, + onClose: { + enabled: true, + delay: 0, + }, }, - }, - })) as any + }), + ) as any const firstCall = orpc.ping('first') @@ -525,13 +607,17 @@ describe('rpcLink', () => { 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 secondSocket.receive( + await createResponseMessage({ id: secondRequest.message.id, body: { json: 'pong-2' } }), + ) await expect(secondCall).resolves.toEqual('pong-2') expect(connect).toHaveBeenCalledTimes(2) }) - it('reconnect on close before the next call arrives ignore background errors', async ({ onTestFinished }) => { + it('reconnect on close before the next call arrives ignore background errors', async ({ + onTestFinished, + }) => { const unhandledRejectionHandler = vi.fn() process.on('unhandledRejection', unhandledRejectionHandler) @@ -540,21 +626,24 @@ describe('rpcLink', () => { }) const firstSocket = createWs() - const connect = vi.fn() + const connect = vi + .fn() .mockImplementationOnce(() => firstSocket) .mockRejectedValueOnce(new Error('TEST')) - const orpc = createORPCClient(new RPCLink({ - connect, - reconnect: { - enabled: true, - maxAttempt: 1, - onClose: { + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { enabled: true, - delay: 0, + maxAttempt: 1, + onClose: { + enabled: true, + delay: 0, + }, }, - }, - })) as any + }), + ) as any const firstCall = orpc.ping('first') @@ -576,17 +665,21 @@ describe('rpcLink', () => { }) const firstSocket = createWs() - const connect = vi.fn(() => { - const ws = createWs(WEBSOCKET_CONNECTING) - setTimeout(() => ws.close()) - return ws - }).mockImplementationOnce(() => firstSocket) - - createORPCClient(new RPCLink({ - connect, - connectOnInit: true, - reconnect: { enabled: true, delay: () => 0, maxAttempt: 2, onClose: { enabled: true } }, - })) + const connect = vi + .fn(() => { + const ws = createWs(WEBSOCKET_CONNECTING) + setTimeout(() => ws.close()) + return ws + }) + .mockImplementationOnce(() => firstSocket) + + createORPCClient( + new RPCLink({ + connect, + connectOnInit: true, + reconnect: { enabled: true, delay: () => 0, maxAttempt: 2, onClose: { enabled: true } }, + }), + ) await vi.advanceTimersByTimeAsync(0) expect(connect).toHaveBeenCalledTimes(1) @@ -603,7 +696,7 @@ describe('rpcLink', () => { const wss = new WebSocketServer({ port: 0 }) wss.on('connection', onConnection) onTestFinished(() => { - wss.clients.forEach(ws => ws.terminate()) + wss.clients.forEach((ws) => ws.terminate()) wss.close() }) return `ws://localhost:${(wss.address() as AddressInfo).port}` @@ -612,7 +705,7 @@ describe('rpcLink', () => { const getRefusedUrl = async () => { const wss = new WebSocketServer({ port: 0 }) const url = `ws://localhost:${(wss.address() as AddressInfo).port}` - await new Promise(resolve => wss.close(resolve)) + await new Promise((resolve) => wss.close(resolve)) return url } @@ -620,23 +713,28 @@ describe('rpcLink', () => { const url = await getRefusedUrl() const orpc = createORPCClient(new RPCLink({ connect: () => new WsWebSocket(url) })) as any - await expect(orpc.ping('input')).rejects.toThrow(new AbortError('WebSocket closed (code 1006: )')) + await expect(orpc.ping('input')).rejects.toThrow( + new AbortError('WebSocket closed (code 1006: )'), + ) }) it('reconnects instead of crashing when the connection is refused', async () => { const refusedUrl = await getRefusedUrl() const url = listen((ws) => { ws.on('message', async (data) => { + // oxlint-disable-next-line typescript/no-base-to-string const request = decodeRequest(data.toString()) ws.send(await createResponseMessage({ id: request.message.id })) }) }) const connect = vi.fn(({ attempt }) => new WsWebSocket(attempt === 1 ? refusedUrl : url)) - const orpc = createORPCClient(new RPCLink({ - connect, - reconnect: { enabled: true, delay: () => 0 }, - })) as any + const orpc = createORPCClient( + new RPCLink({ + connect, + reconnect: { enabled: true, delay: () => 0 }, + }), + ) as any await expect(orpc.ping('input')).resolves.toEqual('pong') expect(connect).toHaveBeenCalledTimes(2) @@ -646,13 +744,15 @@ describe('rpcLink', () => { const url = listen((ws, request) => { ws.on('message', () => { // RSV1 set without a negotiated permessage-deflate extension - request.socket.write(new Uint8Array([0xC1, 0x00])) + request.socket.write(new Uint8Array([0xc1, 0x00])) }) }) const orpc = createORPCClient(new RPCLink({ connect: () => new WsWebSocket(url) })) as any - await expect(orpc.ping('input')).rejects.toThrow(new AbortError('WebSocket closed (code 1006: )')) + await expect(orpc.ping('input')).rejects.toThrow( + new AbortError('WebSocket closed (code 1006: )'), + ) }) }) }) diff --git a/packages/client/src/adapters/websocket/rpc-link.ts b/packages/client/src/adapters/websocket/rpc-link.ts index 2e55faece3..79db74ea5e 100644 --- a/packages/client/src/adapters/websocket/rpc-link.ts +++ b/packages/client/src/adapters/websocket/rpc-link.ts @@ -1,12 +1,11 @@ -import type { ClientContext } from '../../types' -import type { RPCLinkCodecOptions, StandardLinkOptions } from '../standard' -import type { WebSocketLinkTransportOptions } from './transport' -import { RPCLinkCodec, StandardLink } from '../standard' -import { WebSocketLinkTransport } from './transport' +import type { ClientContext } from '../../types.ts' +import type { RPCLinkCodecOptions, StandardLinkOptions } from '../standard/index.ts' +import { RPCLinkCodec, StandardLink } from '../standard/index.ts' +import type { WebSocketLinkTransportOptions } from './transport.ts' +import { WebSocketLinkTransport } from './transport.ts' export interface RPCLinkOptions - extends StandardLinkOptions, WebSocketLinkTransportOptions, RPCLinkCodecOptions { -} + extends StandardLinkOptions, WebSocketLinkTransportOptions, RPCLinkCodecOptions {} /** * Client link that communicates with an RPC Handler over a WebSocket connection. diff --git a/packages/client/src/adapters/websocket/transport.ts b/packages/client/src/adapters/websocket/transport.ts index 7d7406e73d..d0421dd419 100644 --- a/packages/client/src/adapters/websocket/transport.ts +++ b/packages/client/src/adapters/websocket/transport.ts @@ -1,10 +1,24 @@ import type { Promisable } from '@orpc/shared' +import { + AbortError, + loadBytes, + promiseWithResolvers, + runWithSignal, + sequential, + sleep, + toStringOrBytes, +} from '@orpc/shared' import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' import type { DecodePeerMessageOptions, EncodePeerMessageOptions } from '@standard-server/peer' -import type { ClientContext, ClientOptions } from '../../types' -import type { StandardLinkTransport } from '../standard' -import { AbortError, loadBytes, promiseWithResolvers, runWithSignal, sequential, sleep, toStringOrBytes } from '@orpc/shared' -import { ClientPeer, decodePeerMessage, encodePeerMessage, isServerPeerSendMessage } from '@standard-server/peer' +import { + ClientPeer, + decodePeerMessage, + encodePeerMessage, + isServerPeerSendMessage, +} from '@standard-server/peer' + +import type { ClientContext, ClientOptions } from '../../types.ts' +import type { StandardLinkTransport } from '../standard/index.ts' /** * Some env maybe not available WebSocket global, like node 20 @@ -12,7 +26,10 @@ import { ClientPeer, decodePeerMessage, encodePeerMessage, isServerPeerSendMessa const WEBSOCKET_CONNECTING = 0 satisfies WebSocket['CONNECTING'] const WEBSOCKET_OPEN = 1 satisfies WebSocket['OPEN'] -export type WebSocketLike = Pick +export type WebSocketLike = Pick< + WebSocket, + 'addEventListener' | 'removeEventListener' | 'send' | 'readyState' +> export interface WebSocketLinkTransportAttemptInfo { /** @@ -71,23 +88,25 @@ export interface WebSocketLinkTransportReconnectOptions { * * @default { enabled: false } */ - 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: boolean - - /** - * Delay before reconnecting after the socket closes, in milliseconds. - * - * @default 0 - */ - delay?: number | undefined - } | undefined + 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: boolean + + /** + * Delay before reconnecting after the socket closes, in milliseconds. + * + * @default 0 + */ + delay?: number | undefined + } + | undefined } export interface WebSocketLinkTransportOptions<_T extends ClientContext> { @@ -137,10 +156,11 @@ export class WebSocketLinkTransport implements Standard 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.reconnectDelay = options.reconnect?.delay ?? ((info) => (info.attempt === 1 ? 0 : 2_000)) this.reconnectMaxAttempt = options.reconnect?.maxAttempt ?? Infinity this.reconnectMaxTotalAttempt = options.reconnect?.maxTotalAttempt ?? Infinity - this.reconnectOnCloseEnabled = this.reconnectEnabled && (options.reconnect?.onClose?.enabled ?? false) + this.reconnectOnCloseEnabled = + this.reconnectEnabled && (options.reconnect?.onClose?.enabled ?? false) this.reconnectOnCloseDelay = options.reconnect?.onClose?.delay ?? 0 this.encodePeerMessageOptions = options.encodePeerMessage @@ -151,18 +171,19 @@ export class WebSocketLinkTransport implements Standard } } - async send(standardRequest: StandardRequest, _path: string[], _options: ClientOptions): Promise { - const peer = await runWithSignal( - standardRequest.signal, - () => this.getConnectedPeer(), - ) + async send( + standardRequest: StandardRequest, + _path: string[], + _options: ClientOptions, + ): Promise { + const peer = await runWithSignal(standardRequest.signal, () => this.getConnectedPeer()) return peer.request(standardRequest) } private totalAttempt = 0 private attempt = 0 - private current: undefined | Promise + private current: undefined | Promise private async getConnectedPeer(): Promise { const current = this.current const resolved = await current @@ -176,19 +197,27 @@ export class WebSocketLinkTransport implements Standard } if (this.totalAttempt >= this.reconnectMaxTotalAttempt) { - throw new AbortError(`WebSocket reconnect stopped after ${this.totalAttempt} total attempt(s)`) + throw new AbortError( + `WebSocket reconnect stopped after ${this.totalAttempt} total attempt(s)`, + ) } this.current = (async () => { this.totalAttempt += 1 this.attempt += 1 - const info: WebSocketLinkTransportAttemptInfo = { totalAttempt: this.totalAttempt, attempt: this.attempt } + const info: WebSocketLinkTransportAttemptInfo = { + totalAttempt: this.totalAttempt, + attempt: this.attempt, + } await sleep(this.reconnectDelay(info)) const websocket = await this.connect(info) - if (websocket.readyState !== WEBSOCKET_CONNECTING && websocket.readyState !== WEBSOCKET_OPEN) { + if ( + websocket.readyState !== WEBSOCKET_CONNECTING && + websocket.readyState !== WEBSOCKET_OPEN + ) { throw new AbortError('WebSocket is already closing or closed') } @@ -204,7 +233,7 @@ export class WebSocketLinkTransport implements Standard return websocket.send(encoded) }) - let connectingResolvers: undefined | { promise: Promise, resolve: () => void } + let connectingResolvers: undefined | { promise: Promise; resolve: () => void } if (websocket.readyState === WEBSOCKET_CONNECTING) { connectingResolvers = promiseWithResolvers() websocket.addEventListener('open', () => { @@ -216,16 +245,20 @@ export class WebSocketLinkTransport implements Standard * Message order is important: loading -> decode -> .message. * This flow must stay synchronous, or we need to use `sequential` helper */ - websocket.addEventListener('message', sequential(async (event: MessageEvent) => { - // For better compatibility avoid control or depend on websocket.binaryType - const message = event.data instanceof Blob ? await loadBytes(event.data) : toStringOrBytes(event.data) - const result = decodePeerMessage(message, this.decodePeerMessageOptions) - if (result.matched && isServerPeerSendMessage(result.message)) { - // Not awaited: `peer.message` runs handling message may be slow, - // and awaiting it would block decoding of subsequent messages. - peer.message(result.message) - } - })) + websocket.addEventListener( + 'message', + sequential(async (event: MessageEvent) => { + // For better compatibility avoid control or depend on websocket.binaryType + const message = + event.data instanceof Blob ? await loadBytes(event.data) : toStringOrBytes(event.data) + const result = decodePeerMessage(message, this.decodePeerMessageOptions) + if (result.matched && isServerPeerSendMessage(result.message)) { + // Not awaited: `peer.message` runs handling message may be slow, + // and awaiting it would block decoding of subsequent messages. + void peer.message(result.message) + } + }), + ) websocket.addEventListener('close', async (event) => { closeReason = new AbortError(`WebSocket closed (code ${event.code}: ${event.reason})`) @@ -262,7 +295,9 @@ export class WebSocketLinkTransport implements Standard const attempt = this.attempt this.attempt = 0 this.current = undefined - throw new AbortError(`WebSocket reconnect failed after ${attempt} attempt(s)`, { cause: error }) + throw new AbortError(`WebSocket reconnect failed after ${attempt} attempt(s)`, { + cause: error, + }) } }) diff --git a/packages/client/src/async-iterator-object.test.ts b/packages/client/src/async-iterator-object.test.ts index a881ac7fb2..d351e1544f 100644 --- a/packages/client/src/async-iterator-object.test.ts +++ b/packages/client/src/async-iterator-object.test.ts @@ -1,5 +1,6 @@ import { getEventMeta, withEventMeta } from '@standard-server/core' -import { wrapAsyncIteratorPreservingEventMeta } from './async-iterator-object' + +import { wrapAsyncIteratorPreservingEventMeta } from './async-iterator-object.ts' describe('wrapAsyncIteratorPreservingEventMeta', () => { it('preserves metadata when mapping yielded and returned values', async () => { @@ -49,7 +50,7 @@ describe('wrapAsyncIteratorPreservingEventMeta', () => { return returned })() - const mapResult = vi.fn(async result => result) + const mapResult = vi.fn(async (result) => result) const mapped = wrapAsyncIteratorPreservingEventMeta(iterator, { mapResult, @@ -69,7 +70,7 @@ describe('wrapAsyncIteratorPreservingEventMeta', () => { 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 } as any)) + const mapError = vi.fn(async (cause) => ({ mapped: cause }) as any) const iterator = (async function* () { throw error @@ -96,19 +97,25 @@ describe('wrapAsyncIteratorPreservingEventMeta', () => { 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 sameError = wrapAsyncIteratorPreservingEventMeta((async function* () { - throw error - })(), { - mapError: async cause => cause, - }) + const sameError = wrapAsyncIteratorPreservingEventMeta( + (async function* () { + throw error + })(), + { + mapError: async (cause) => cause, + }, + ) await expect(sameError.next()).rejects.toBe(error) - const primitiveError = wrapAsyncIteratorPreservingEventMeta((async function* () { - throw error - })(), { - mapError: async () => 'mapped error' as any, - }) + const primitiveError = wrapAsyncIteratorPreservingEventMeta( + (async function* () { + throw error + })(), + { + mapError: async () => 'mapped error' as any, + }, + ) await expect(primitiveError.next()).rejects.toBe('mapped error') }) diff --git a/packages/client/src/async-iterator-object.ts b/packages/client/src/async-iterator-object.ts index eb02aeed35..130e6967df 100644 --- a/packages/client/src/async-iterator-object.ts +++ b/packages/client/src/async-iterator-object.ts @@ -2,35 +2,48 @@ import type { AsyncIteratorClass, WrapAsyncIteratorOptions } from '@orpc/shared' import { isTypescriptObject, wrapAsyncIterator } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@standard-server/core' -export function wrapAsyncIteratorPreservingEventMeta( +export function wrapAsyncIteratorPreservingEventMeta< + TYield, + TReturn, + TMappedYield = TYield, + TMappedReturn = TReturn, +>( iterator: AsyncIterator, - { mapResult, mapError, ...rest }: WrapAsyncIteratorOptions, + { + mapResult, + mapError, + ...rest + }: WrapAsyncIteratorOptions, ): AsyncIteratorClass { return wrapAsyncIterator(iterator, { ...rest, - mapResult: mapResult && (async (result) => { - const mapped = await mapResult(result) + mapResult: + mapResult && + (async (result) => { + const mapped = await mapResult(result) - 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 + 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 mapped - }), - mapError: mapError && (async (error) => { - const mapped = await mapError(error) + return mapped + }), + mapError: + mapError && + (async (error) => { + const mapped = await mapError(error) - if (mapped !== error) { - const meta = getEventMeta(error) - if (meta && isTypescriptObject(mapped)) { - return withEventMeta(mapped, meta) + if (mapped !== error) { + const meta = getEventMeta(error) + if (meta && isTypescriptObject(mapped)) { + return withEventMeta(mapped, meta) + } } - } - return mapped - }), + return mapped + }), }) } diff --git a/packages/client/src/client-safe.test-d.ts b/packages/client/src/client-safe.test-d.ts index c96af72048..25887dccc4 100644 --- a/packages/client/src/client-safe.test-d.ts +++ b/packages/client/src/client-safe.test-d.ts @@ -1,6 +1,6 @@ -import type { SafeClient } from './client-safe' -import type { ORPCError } from './error' -import type { Client, ClientContext } from './types' +import type { SafeClient } from './client-safe.ts' +import type { ORPCError } from './error.ts' +import type { Client, ClientContext } from './types.ts' it('SafeClient', async () => { const client = {} as { @@ -13,9 +13,14 @@ it('SafeClient', async () => { const safeClient = {} as SafeClient const pingResult = await safeClient.ping('test') - expectTypeOf(pingResult.error).toEqualTypeOf | null>() + expectTypeOf(pingResult.error).toEqualTypeOf< + Error | ORPCError<'BAD_GATEWAY', { val: string }> | null + >() expectTypeOf(pingResult.data).toEqualTypeOf() - expectTypeOf(pingResult.definedError).toEqualTypeOf | null>() + expectTypeOf(pingResult.definedError).toEqualTypeOf | null>() expectTypeOf(pingResult.isSuccess).toEqualTypeOf() const pongResult = await safeClient.nested.pong({ id: 123 }) diff --git a/packages/client/src/client-safe.test.ts b/packages/client/src/client-safe.test.ts index 0b419be690..026eab18b6 100644 --- a/packages/client/src/client-safe.test.ts +++ b/packages/client/src/client-safe.test.ts @@ -1,4 +1,4 @@ -import { createSafeClient } from './client-safe' +import { createSafeClient } from './client-safe.ts' beforeEach(() => { vi.clearAllMocks() diff --git a/packages/client/src/client-safe.ts b/packages/client/src/client-safe.ts index b101d54031..2e001adeb2 100644 --- a/packages/client/src/client-safe.ts +++ b/packages/client/src/client-safe.ts @@ -1,11 +1,12 @@ -import type { AnyNestedClient, Client, ClientRest } from './types' -import type { SafeResult } from './utils' import { isTypescriptObject } from '@orpc/shared' -import { RECURSIVE_CLIENT_UNWRAP_KEYS } from './consts' -import { safe } from './utils' -export type SafeClient - = T extends Client +import { RECURSIVE_CLIENT_UNWRAP_KEYS } from './consts.ts' +import type { AnyNestedClient, Client, ClientRest } from './types.ts' +import type { SafeResult } from './utils.ts' +import { safe } from './utils.ts' + +export type SafeClient = + T extends Client ? (...rest: ClientRest) => Promise> : { [K in keyof T]: T[K] extends AnyNestedClient ? SafeClient : never diff --git a/packages/client/src/client.test-d.ts b/packages/client/src/client.test-d.ts index 0c28b37f09..f6237b9943 100644 --- a/packages/client/src/client.test-d.ts +++ b/packages/client/src/client.test-d.ts @@ -1,96 +1,132 @@ import type { RouterContractClient } from '@orpc/contract' import type { RouterClient } from '@orpc/server' -import type { PromiseWithError } from '@orpc/shared' -import type { ORPCError } from './error' -import type { ClientLink } from './types' import { os, type } from '@orpc/server' -import { createORPCClient } from './client' +import type { PromiseWithError } from '@orpc/shared' + +import { createORPCClient } from './client.ts' +import type { ORPCError } from './error.ts' +import type { ClientLink } from './types.ts' const router = { ping: os.input(type()).handler(() => 'pong'), nested: { - pong: os.input(type()).errors({ TEST: { data: type() } }).handler(({ errors }) => { throw errors.TEST({ data: 'string' }) }), + pong: os + .input(type()) + .errors({ TEST: { data: type() } }) + .handler(({ errors }) => { + throw errors.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 }>) + 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 }>, + ) + + const _11111: RouterClient = createORPCClient( + // @ts-expect-error -- cache is required + {} as ClientLink<{ cache: string }>, + ) + + const _2: RouterClient = createORPCClient( + // @ts-expect-error -- expect cache is optional + {} as ClientLink<{ cache: string }>, + ) + + const _3: RouterClient = createORPCClient( + // @ts-expect-error -- expect cache is number + {} as ClientLink<{ cache?: string }>, + ) + + const _4: RouterContractClient = createORPCClient( + {} as ClientLink<{ cache?: string }>, + ) + + const _44: RouterContractClient = createORPCClient( + // @ts-expect-error -- cache is required + {} 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 _client: RouterClient = createORPCClient( + {} as ClientLink<{ cache: string }>, + { + interceptors: [ + ({ input, context, next }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache: string }>() - const result = next() + const result = next() - expectTypeOf(result).toEqualTypeOf>>() + expectTypeOf(result).toEqualTypeOf< + PromiseWithError> + >() - return result - }, - ], - }) + 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: { + const _client: RouterClient = createORPCClient( + {} as ClientLink<{ cache: string }>, + { + scoped: { + ping: { interceptors: [ ({ input, context, next }) => { - expectTypeOf(input).toEqualTypeOf() + expectTypeOf(input).toEqualTypeOf() expectTypeOf(context).toEqualTypeOf<{ cache: string }>() const result = next() - expectTypeOf(result).toEqualTypeOf>>() + 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< + PromiseWithError> + >() + + return result + }, + ], + }, + }, - // @ts-expect-error - non exists - nonExist: {}, + // @ts-expect-error - non exists + nonExist: {}, + }, }, - }) + ) }) }) diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index a318874300..2943fd90c3 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1,5 +1,5 @@ -import type { ClientContext, ClientLink } from './types' -import { createORPCClient } from './client' +import { createORPCClient } from './client.ts' +import type { ClientContext, ClientLink } from './types.ts' beforeEach(() => { vi.clearAllMocks() @@ -20,7 +20,11 @@ describe('createORPCClient', () => { vi.clearAllMocks() expect(await client.nested.pong({ value: 'hello' })).toEqual('__mocked__') expect(mockedLink.call).toHaveBeenCalledTimes(1) - expect(mockedLink.call).toHaveBeenCalledWith(['nested', 'pong'], { value: 'hello' }, { context: {} }) + expect(mockedLink.call).toHaveBeenCalledWith( + ['nested', 'pong'], + { value: 'hello' }, + { context: {} }, + ) }) it('works with signal', async () => { @@ -30,15 +34,25 @@ describe('createORPCClient', () => { expect(await client.ping({ value: 'hello' }, { signal })).toEqual('__mocked__') expect(mockedLink.call).toHaveBeenCalledTimes(1) - expect(mockedLink.call).toHaveBeenCalledWith(['ping'], { value: 'hello' }, { signal, context: {} }) + 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(await client.ping({ value: 'hello' }, { context: { userId: '123' } })).toEqual( + '__mocked__', + ) expect(mockedLink.call).toHaveBeenCalledTimes(1) - expect(mockedLink.call).toHaveBeenCalledWith(['ping'], { value: 'hello' }, { context: { userId: '123' } }) + expect(mockedLink.call).toHaveBeenCalledWith( + ['ping'], + { value: 'hello' }, + { context: { userId: '123' } }, + ) }) it('works with base path', async () => { @@ -46,7 +60,11 @@ describe('createORPCClient', () => { expect(await client.ping({ value: 'hello' })).toEqual('__mocked__') expect(mockedLink.call).toHaveBeenCalledTimes(1) - expect(mockedLink.call).toHaveBeenCalledWith(['base', 'ping'], { value: 'hello' }, { context: {} }) + expect(mockedLink.call).toHaveBeenCalledWith( + ['base', 'ping'], + { value: 'hello' }, + { context: {} }, + ) }) it('works with interceptors', async () => { @@ -98,7 +116,9 @@ describe('createORPCClient', () => { interceptors: [firstInterceptor, secondInterceptor], }) as any - expect(await client.ping({ value: 'hello' }, { context: { requestId: 'request_1' }, signal })).toEqual('__mocked__') + expect( + await client.ping({ value: 'hello' }, { context: { requestId: 'request_1' }, signal }), + ).toEqual('__mocked__') expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after']) expect(firstInterceptor).toHaveBeenCalledTimes(1) @@ -113,11 +133,13 @@ describe('createORPCClient', () => { }) it('works with scoped', async () => { - const rootInterceptor = vi.fn(({ path, input, context, next }) => next({ - path, - input, - context: { ...context, rootPath: path.join('.') }, - })) + 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']) @@ -157,8 +179,12 @@ describe('createORPCClient', () => { }, } 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(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) @@ -208,7 +234,11 @@ describe('createORPCClient', () => { expect(mockedLink.call).toHaveBeenLastCalledWith(['ping'], { value: 'call' }, { context: {} }) expect(await client.nested.pong.apply(undefined, [{ value: 'apply' }])).toEqual('__mocked__') - expect(mockedLink.call).toHaveBeenLastCalledWith(['nested', 'pong'], { value: 'apply' }, { context: {} }) + expect(mockedLink.call).toHaveBeenLastCalledWith( + ['nested', 'pong'], + { value: 'apply' }, + { context: {} }, + ) expect(await client.ping.bind(undefined)({ value: 'bind' })).toEqual('__mocked__') expect(mockedLink.call).toHaveBeenLastCalledWith(['ping'], { value: 'bind' }, { context: {} }) diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 3512815c7b..c0c139e1a3 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,27 +1,50 @@ import type { Interceptor, PromiseWithError } from '@orpc/shared' -import type { AnyNestedClient, Client, ClientContext, ClientLink, ClientOptions, InferClientContext, InferClientError } from './types' import { intercept, toArray } from '@orpc/shared' -import { RECURSIVE_CLIENT_UNWRAP_KEYS } from './consts' -import { resolveClientRest } from './utils' -export interface ORPCClientInterceptorOptions extends ClientOptions { +import { RECURSIVE_CLIENT_UNWRAP_KEYS } from './consts.ts' +import type { + AnyNestedClient, + Client, + ClientContext, + ClientLink, + ClientOptions, + InferClientContext, + InferClientError, +} from './types.ts' +import { resolveClientRest } from './utils.ts' + +export interface ORPCClientInterceptorOptions< + TClientContext extends ClientContext, + TInput, +> extends ClientOptions { path: string[] input: TInput } -export type ORPCClientInterceptor - = Interceptor, PromiseWithError> - -export interface ORPCClientScopedOptions { - +export type ORPCClientInterceptor< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> = Interceptor< + ORPCClientInterceptorOptions, + PromiseWithError +> + +export interface ORPCClientScopedOptions< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> { /** * Interceptors that wrap the entire client call lifecycle. */ interceptors?: ORPCClientInterceptor[] | undefined } -export type ORPCClientScoped - = T extends Client +export type ORPCClientScoped = + T extends Client ? ORPCClientScopedOptions : { [K in keyof T]?: (T[K] extends AnyNestedClient ? ORPCClientScoped : never) | undefined @@ -36,7 +59,9 @@ export interface ORPCClientOptions { /** * Interceptors that wrap the entire client call lifecycle, applied to every procedure call. */ - interceptors?: ORPCClientInterceptor, unknown, unknown, InferClientError>[] | undefined + interceptors?: + | ORPCClientInterceptor, unknown, unknown, InferClientError>[] + | undefined /** * Per-procedure options following the shape of the router. @@ -57,11 +82,18 @@ export function createORPCClient( link: ClientLink>, { path = [], ...options }: NoInfer> = {}, ): T { - const procedureClient: Client, unknown, unknown, InferClientError> = (...rest) => { + 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>[], + ...(toArray(options.scoped?.interceptors) as ORPCClientInterceptor< + InferClientContext, + unknown, + unknown, + InferClientError + >[]), ] return intercept( @@ -85,7 +117,9 @@ export function createORPCClient( client = createORPCClient(link, { ...options, path: [...path, key], - scoped: (options.scoped as undefined | Record['scoped']>)?.[key], + scoped: (options.scoped as undefined | Record['scoped']>)?.[ + key + ], }) cache.set(key, client) diff --git a/packages/client/src/dynamic-link.test-d.ts b/packages/client/src/dynamic-link.test-d.ts index ecc72a0d4f..e4761d68d2 100644 --- a/packages/client/src/dynamic-link.test-d.ts +++ b/packages/client/src/dynamic-link.test-d.ts @@ -1,5 +1,5 @@ -import type { ClientLink } from './types' -import { DynamicLink } from './dynamic-link' +import { DynamicLink } from './dynamic-link.ts' +import type { ClientLink } from './types.ts' describe('dynamicLink', () => { it('pass correct context', () => { @@ -11,10 +11,10 @@ describe('dynamicLink', () => { }) it('required return a valid link', () => { - void new DynamicLink(() => ({} as ClientLink)) - void new DynamicLink<{ batch?: boolean }>(() => ({} as ClientLink<{ batch?: boolean }>)) + void new DynamicLink(() => ({}) as ClientLink) + void new DynamicLink<{ batch?: boolean }>(() => ({}) as ClientLink<{ batch?: boolean }>) // @ts-expect-error - context is mismatch - void new DynamicLink<{ batch?: boolean }>(() => ({} as ClientLink<{ batch?: string }>)) + void new DynamicLink<{ batch?: boolean }>(() => ({}) as ClientLink<{ batch?: string }>) // @ts-expect-error - must return a ClientLink void new DynamicLink<{ batch?: boolean }>(() => ({})) }) diff --git a/packages/client/src/dynamic-link.test.ts b/packages/client/src/dynamic-link.test.ts index f2597000c7..b4387b8106 100644 --- a/packages/client/src/dynamic-link.test.ts +++ b/packages/client/src/dynamic-link.test.ts @@ -1,5 +1,5 @@ -import type { ClientOptions } from './types' -import { DynamicLink } from './dynamic-link' +import { DynamicLink } from './dynamic-link.ts' +import type { ClientOptions } from './types.ts' it('dynamicLink', async () => { const mockedLink = { call: vi.fn().mockResolvedValue('__mocked__') } diff --git a/packages/client/src/dynamic-link.ts b/packages/client/src/dynamic-link.ts index 8cec07d02a..b64c2b61c9 100644 --- a/packages/client/src/dynamic-link.ts +++ b/packages/client/src/dynamic-link.ts @@ -1,5 +1,6 @@ import type { Promisable } from '@orpc/shared' -import type { ClientContext, ClientLink, ClientOptions } from './types' + +import type { ClientContext, ClientLink, ClientOptions } from './types.ts' /** * DynamicLink provides a way to dynamically resolve and delegate calls to other ClientLinks @@ -7,17 +8,22 @@ import type { ClientContext, ClientLink, ClientOptions } from './types' * * @see {@link https://orpc.dev/docs/client/dynamic-link | DynamicLink} */ -export class DynamicLink implements ClientLink { +export class DynamicLink< + TClientContext extends ClientContext, +> implements ClientLink { constructor( private readonly linkResolver: ( options: ClientOptions, path: string[], input: unknown, ) => Promisable>, - ) { - } + ) {} - async call(path: 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 index 25839c5dbb..d68bf111ce 100644 --- a/packages/client/src/error-utils.test-d.ts +++ b/packages/client/src/error-utils.test-d.ts @@ -1,18 +1,22 @@ -import type { ORPCError } from './error' -import { isDefinedError } from './error-utils' +import { isDefinedError } from './error-utils.ts' +import type { ORPCError } from './error.ts' describe('isDefinedError', () => { it('normal', () => { - const error = { } as ORPCError<'BAD_REQUEST', { id: number }> | ORPCError<'CONFLICT', unknown> | Error + const error = {} as + | ORPCError<'BAD_REQUEST', { id: number }> + | ORPCError<'CONFLICT', unknown> + | Error if (isDefinedError(error)) { - expectTypeOf().toEqualTypeOf | ORPCError<'CONFLICT', unknown>>() + expectTypeOf().toEqualTypeOf< + ORPCError<'BAD_REQUEST', { id: number }> | ORPCError<'CONFLICT', unknown> + >() if (error.code === 'BAD_REQUEST') { expectTypeOf().toEqualTypeOf() } - } - else { + } else { expectTypeOf().toEqualTypeOf() } }) @@ -22,8 +26,7 @@ describe('isDefinedError', () => { if (isDefinedError(error)) { expectTypeOf().toEqualTypeOf() - } - else { + } else { // @ts-expect-error FIX: should be any expectTypeOf().toEqualTypeOf() } @@ -35,8 +38,7 @@ describe('isDefinedError', () => { if (isDefinedError(error)) { // @ts-expect-error FIX: should be unknown or any expectTypeOf().toEqualTypeOf() - } - else { + } else { expectTypeOf().toEqualTypeOf() } }) diff --git a/packages/client/src/error-utils.test.ts b/packages/client/src/error-utils.test.ts index b03e76b801..e90b5b47a6 100644 --- a/packages/client/src/error-utils.test.ts +++ b/packages/client/src/error-utils.test.ts @@ -1,12 +1,13 @@ import type { Writable } from '@orpc/shared' -import { ORPCError } from './error' + import { cloneORPCError, createORPCErrorFromJson, isDefinedError, isORPCErrorJson, toORPCError, -} from './error-utils' +} from './error-utils.ts' +import { ORPCError } from './error.ts' it('isDefinedError', () => { const definedError = new ORPCError('BAD_REQUEST') @@ -49,7 +50,11 @@ describe('toORPCError', () => { }) describe('isORPCErrorJson', () => { - const error = new ORPCError('BAD_REQUEST', { message: 'Bad request', cause: 'cause', data: 'data' }) + const error = new ORPCError('BAD_REQUEST', { + message: 'Bad request', + cause: 'cause', + data: 'data', + }) ;(error as any).defined = true as any it('returns true for valid ORPC error JSON', () => { @@ -124,7 +129,11 @@ describe('isORPCErrorJson', () => { }) describe('createORPCErrorFromJson', () => { - const error = new ORPCError('BAD_REQUEST', { message: 'Bad request', cause: 'cause', data: 'data' }) + 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', () => { @@ -238,6 +247,7 @@ describe('cloneORPCError', () => { expect(Object.prototype.toString.call(cloned)).toBe('[object Error]') expect(Object.keys(cloned)).toEqual(Object.keys(original)) + // oxlint-disable-next-line typescript/no-misused-spread expect({ ...cloned }).toEqual({ ...original }) }) }) diff --git a/packages/client/src/error-utils.ts b/packages/client/src/error-utils.ts index 2df34e0011..63c92953b4 100644 --- a/packages/client/src/error-utils.ts +++ b/packages/client/src/error-utils.ts @@ -1,7 +1,8 @@ import type { Writable } from '@orpc/shared' -import type { AnyORPCError, ORPCErrorCode, ORPCErrorJSON } from './error' import { isPlainObject } from '@orpc/shared' -import { ORPCError } from './error' + +import type { AnyORPCError, ORPCErrorCode, ORPCErrorJSON } from './error.ts' +import { ORPCError } from './error.ts' /** * Checks if an error is an `ORPCError` that matches a definition in the procedure's `.errors` map, @@ -13,7 +14,9 @@ export function isDefinedError(error: T): error is Extract { return error instanceof ORPCError && error.defined } -export function toORPCError(error: T): Extract | ORPCError<'INTERNAL_SERVER_ERROR', undefined> { +export function toORPCError( + error: T, +): Extract | ORPCError<'INTERNAL_SERVER_ERROR', undefined> { return error instanceof ORPCError ? error : new ORPCError('INTERNAL_SERVER_ERROR', { cause: error }) @@ -25,22 +28,24 @@ export function isORPCErrorJson(json: unknown): json is ORPCErrorJSON !validKeys.includes(k))) { + 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' - && 'message' in json - && typeof json.message === 'string' + return ( + 'defined' in json && + typeof json.defined === 'boolean' && + 'code' in json && + typeof json.code === 'string' && + 'message' in json && + typeof json.message === 'string' + ) } export function createORPCErrorFromJson( json: ORPCErrorJSON, options: ErrorOptions = {}, -): ORPCError { +): ORPCError { const error = new ORPCError(json.code, { ...json, ...options, diff --git a/packages/client/src/error.test-d.ts b/packages/client/src/error.test-d.ts index 421f64a748..cb64812087 100644 --- a/packages/client/src/error.test-d.ts +++ b/packages/client/src/error.test-d.ts @@ -1,11 +1,15 @@ -import { ORPCError } from './error' +import { ORPCError } from './error.ts' describe('ORPCError', () => { it('constructor', () => { const _error11: ORPCError<'CODE', undefined | 'optional'> = new ORPCError('CODE') - const _error12: ORPCError<'CODE', undefined | 'optional'> = new ORPCError('CODE', { data: 'optional' }) + 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' }) + const _error13: ORPCError<'CODE', undefined | 'optional'> = new ORPCError('CODE', { + data: 'invalid', + }) // @ts-expect-error - data is required const _error21: ORPCError<'CODE', 'required'> = new ORPCError('CODE') diff --git a/packages/client/src/error.test.ts b/packages/client/src/error.test.ts index f8f3ef3288..04b7b82bcc 100644 --- a/packages/client/src/error.test.ts +++ b/packages/client/src/error.test.ts @@ -1,5 +1,6 @@ import { NullProtoObj } from '@orpc/shared' -import { MalformedResponseError, ORPCError, ResponseError } from './error' + +import { MalformedResponseError, ORPCError, ResponseError } from './error.ts' describe('oRPCError', () => { it('works', () => { @@ -43,6 +44,7 @@ describe('oRPCError', () => { it('instanceof should behave as normal', () => { class ExtendedORPCError extends ORPCError {} + // oxlint-disable-next-line typescript/no-extraneous-class class NotRelated {} const orpcError = new ORPCError('test') @@ -113,7 +115,11 @@ describe('oRPCError', () => { describe('responseError', () => { it('works', () => { - const response = { status: 404, headers: { 'x-request-id': 'abc' }, body: { message: 'ignored' } } + const response = { + status: 404, + headers: { 'x-request-id': 'abc' }, + body: { message: 'ignored' }, + } const cause = new Error('cause') const error = new ResponseError({ message: 'custom message', response, cause }) @@ -127,7 +133,9 @@ describe('responseError', () => { }) it('infers message from the response status', () => { - const error = new ResponseError({ response: { status: 404, headers: {}, body: { message: 'ignored' } } }) + const error = new ResponseError({ + response: { status: 404, headers: {}, body: { message: 'ignored' } }, + }) expect(error.message).toBe('Received a 404 response.') expect('cause' in error).toBe(false) @@ -136,9 +144,17 @@ describe('responseError', () => { describe('malformedResponseError', () => { it('works', () => { - const response = { status: 500, headers: { 'x-header': 'value' }, body: { something: 'unexpected' } } + const response = { + status: 500, + headers: { 'x-header': 'value' }, + body: { something: 'unexpected' }, + } const cause = new Error('deserialize failed') - const error = new MalformedResponseError({ message: 'Invalid RPC response format.', response, cause }) + const error = new MalformedResponseError({ + message: 'Invalid RPC response format.', + response, + cause, + }) expect(error).toBeInstanceOf(Error) expect(error).toBeInstanceOf(ResponseError) @@ -150,21 +166,39 @@ describe('malformedResponseError', () => { }) it('infers message from a string body', () => { - const error = new MalformedResponseError({ response: { status: 500, headers: {}, body: 'upstream exploded' } }) + const error = new MalformedResponseError({ + response: { status: 500, headers: {}, body: 'upstream exploded' }, + }) expect(error.message).toBe('upstream exploded') }) it('infers message from body.message', () => { - const error = new MalformedResponseError({ response: { status: 500, headers: {}, body: { message: 'upstream exploded', detail: 'ignored' } } }) + const error = new MalformedResponseError({ + response: { + status: 500, + headers: {}, + body: { message: 'upstream exploded', detail: 'ignored' }, + }, + }) expect(error.message).toBe('upstream exploded') }) it('infers message from a common error code matching the status', () => { - expect(new MalformedResponseError({ response: { status: 404, headers: {}, body: { detail: 'no message here' } } }).message).toBe('Not Found') - expect(new MalformedResponseError({ response: { status: 503, headers: {}, body: undefined } }).message).toBe('Service Unavailable') - expect(new MalformedResponseError({ response: { status: 411, headers: {}, body: undefined } }).message).toBe('Length Required') + expect( + new MalformedResponseError({ + response: { status: 404, headers: {}, body: { detail: 'no message here' } }, + }).message, + ).toBe('Not Found') + expect( + new MalformedResponseError({ response: { status: 503, headers: {}, body: undefined } }) + .message, + ).toBe('Service Unavailable') + expect( + new MalformedResponseError({ response: { status: 411, headers: {}, body: undefined } }) + .message, + ).toBe('Length Required') }) it.each([ @@ -174,9 +208,12 @@ describe('malformedResponseError', () => { ['oversized body.message', { message: 'x'.repeat(257) }], ['non-string body.message', { message: 42 }], ['non-object body', 42], - ])('falls back to the default message when the status is uncommon and the body has no message (%s)', (_, body) => { - const error = new MalformedResponseError({ response: { status: 599, headers: {}, body } }) - - expect(error.message).toBe('Malformed oRPC Response') - }) + ])( + 'falls back to the default message when the status is uncommon and the body has no message (%s)', + (_, body) => { + const error = new MalformedResponseError({ response: { status: 599, headers: {}, body } }) + + expect(error.message).toBe('Malformed oRPC Response') + }, + ) }) diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts index 1906fe7611..07ebbade4c 100644 --- a/packages/client/src/error.ts +++ b/packages/client/src/error.ts @@ -1,6 +1,6 @@ import type { MaybeOptionalOptions, Registry } from '@orpc/shared' -import type { StandardResponse } from '@standard-server/core' import { getConstructors, isPlainObject, resolveMaybeOptionalOptions } from '@orpc/shared' +import type { StandardResponse } from '@standard-server/core' /** * Default mapping between common oRPC error codes and HTTP status codes. @@ -35,15 +35,13 @@ export const COMMON_ERROR_STATUS_MAP = { GATEWAY_TIMEOUT: 504, } -export type ORPCErrorCode - = Registry extends { ORPCErrorCode: infer T extends string } - ? T - : (keyof typeof COMMON_ERROR_STATUS_MAP) | (string & {}) +export type ORPCErrorCode = Registry extends { ORPCErrorCode: infer T extends string } + ? T + : keyof typeof COMMON_ERROR_STATUS_MAP | (string & {}) -export type ORPCErrorOptions - = & ErrorOptions - & { message?: string | undefined } - & (undefined extends TData ? { data?: TData } : { data: TData }) +export type ORPCErrorOptions = ErrorOptions & { + message?: string | undefined +} & (undefined extends TData ? { data?: TData } : { data: TData }) let ORPCErrorConstructors: WeakSet @@ -128,7 +126,7 @@ 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 { + static override [Symbol.hasInstance](instance: unknown): boolean { if (!ORPCErrorConstructors.has(this)) { // not applicable to extended classes return super[Symbol.hasInstance](instance) @@ -144,7 +142,10 @@ export class ORPCError extends Error { } } -export interface ORPCErrorJSON extends Pick, 'code' | 'message' | 'data'> { +export interface ORPCErrorJSON extends Pick< + ORPCError, + 'code' | 'message' | 'data' +> { /** * remove readonly */ @@ -205,7 +206,10 @@ export class MalformedResponseError extends ResponseError { override readonly name = 'MalformedResponseError' constructor(options: MalformedResponseErrorOptions) { - super({ ...options, message: options.message ?? inferMalformedResponseMessage(options.response) }) + super({ + ...options, + message: options.message ?? inferMalformedResponseMessage(options.response), + }) } } @@ -218,15 +222,24 @@ const INFERRED_MESSAGE_MAX_LENGTH = 256 function inferMalformedResponseMessage(response: StandardResponse): string { const candidate = isPlainObject(response.body) ? response.body.message : response.body - if (typeof candidate === 'string' && candidate.length > 0 && candidate.length <= INFERRED_MESSAGE_MAX_LENGTH) { + if ( + typeof candidate === 'string' && + candidate.length > 0 && + candidate.length <= INFERRED_MESSAGE_MAX_LENGTH + ) { return candidate } - const commonCode = Object.entries(COMMON_ERROR_STATUS_MAP).find(([, status]) => status === response.status)?.[0] + const commonCode = Object.entries(COMMON_ERROR_STATUS_MAP).find( + ([, status]) => status === response.status, + )?.[0] return commonCode ? codeToMessage(commonCode) : 'Malformed oRPC Response' } function codeToMessage(code: string): string { - return code.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ') + return code + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' ') } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index ca43438720..65b1ed0a90 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1,9 +1,11 @@ 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), - })) + await expect(import('./index.ts')).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 258e1dd93a..2e958d874b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,21 +1,21 @@ -export * from './async-iterator-object' -export * from './client' -export * from './client-safe' -export * from './consts' -export * from './dynamic-link' -export * from './error' -export * from './error-utils' -export * from './rpc-json-serializer' -export * from './rpc-serializer' -export * from './types' +export * from './async-iterator-object.ts' +export * from './client.ts' +export * from './client-safe.ts' +export * from './consts.ts' +export * from './dynamic-link.ts' +export * from './error.ts' +export * from './error-utils.ts' +export * from './rpc-json-serializer.ts' +export * from './rpc-serializer.ts' +export * from './types.ts' export type { /** * @deprecated Use `InferClientError` instead. */ InferClientError as InferClientErrorUnion, -} from './types' -export { resolveClientRest, safe } from './utils' -export type { SafeResult } from './utils' +} from './types.ts' +export { resolveClientRest, safe } from './utils.ts' +export type { SafeResult } from './utils.ts' export type { AsyncCleanupFn, diff --git a/packages/client/src/plugins/batch.test.ts b/packages/client/src/plugins/batch.test.ts index 3dec5018b0..4cbdccb211 100644 --- a/packages/client/src/plugins/batch.test.ts +++ b/packages/client/src/plugins/batch.test.ts @@ -1,10 +1,12 @@ -import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' import { AsyncLocalStorage } from 'node:async_hooks' + import { promiseWithResolvers, sleep } from '@orpc/shared' +import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' import { encodePeerMessage } from '@standard-server/peer' -import { StandardLink } from '../adapters/standard' -import { BatchLinkPlugin } from './batch' + +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard/index.ts' +import { StandardLink } from '../adapters/standard/index.ts' +import { BatchLinkPlugin } from './batch.ts' interface TestContext { tag?: string @@ -38,18 +40,26 @@ function extractBatchMessagesFromRequest(request: StandardRequest): any[] { return match ? JSON.parse(decodeURIComponent(match[1]!)) : [] } -function makeBufferedBatchResponseFromRequest(request: StandardRequest, resultFn?: (id: unknown, index: number) => unknown): StandardLazyResponse { +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, - })), + 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, + })), } } @@ -97,29 +107,41 @@ async function toLengthPrefixedBytes(messages: any[]): Promise Promise }>() - - const link = new StandardLink(makeCodec(), { - send: async (request) => { - const stream = new ReadableStream({ - start(controller) { - batch.resolve({ - ids: extractBatchMessagesFromRequest(request).map(message => message.id), - signal: request.signal!, - push: async message => controller.enqueue(await toLengthPrefixedBytes([message])), - }) - }, - }) + const batch = promiseWithResolvers<{ + ids: string[] + signal: AbortSignal + push: (message: any) => Promise + }>() + + const link = new StandardLink( + makeCodec(), + { + send: async (request) => { + const stream = new ReadableStream({ + start(controller) { + batch.resolve({ + ids: extractBatchMessagesFromRequest(request).map((message) => message.id), + signal: request.signal!, + push: async (message) => controller.enqueue(await toLengthPrefixedBytes([message])), + }) + }, + }) - return { status: 207, headers: {}, resolveBody: async () => stream } + return { status: 207, headers: {}, resolveBody: async () => stream } + }, }, - }, { - plugins: [new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }] })], - }) + { + plugins: [new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }] })], + }, + ) const outputA = link.call(['a'], {}, { context: {} }) const outputB = link.call(['b'], {}, { context: {} }) - const { ids: [idA, idB], signal, push } = await batch.promise + const { + ids: [idA, idB], + signal, + push, + } = await batch.promise return { outputA, outputB, idA, idB, signal, push } } @@ -143,10 +165,12 @@ describe('batchLinkPlugin', () => { const condition = vi.fn(() => true) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [{ condition, context: () => ({}) }], - filter, - })], + plugins: [ + new BatchLinkPlugin({ + groups: [{ condition, context: () => ({}) }], + filter, + }), + ], }) await Promise.all([ @@ -166,9 +190,11 @@ describe('batchLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [{ condition: () => false, context: () => ({}) }], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [{ condition: () => false, context: () => ({}) }], + }), + ], }) await Promise.all([ @@ -184,9 +210,11 @@ describe('batchLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) await expect(link.call(['ping'], {}, { context: {} })).resolves.toBe('not-batched') @@ -205,9 +233,11 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) await Promise.all([ @@ -230,9 +260,11 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) await Promise.all([ @@ -261,9 +293,11 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) await Promise.all([ @@ -296,9 +330,11 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) const result = await link.call(['upload-iterator'], {}, { context: {} }) @@ -329,9 +365,11 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) await Promise.all([ @@ -349,9 +387,11 @@ describe('batchLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) await Promise.all([ @@ -370,10 +410,12 @@ describe('batchLinkPlugin', () => { const mode = vi.fn(async () => 'streaming' as const) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - mode, - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + mode, + }), + ], }) await Promise.all([ @@ -395,10 +437,12 @@ describe('batchLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - maxSize: 2, - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + maxSize: 2, + }), + ], }) // 4 concurrent requests with maxSize 2 should split into 2 batches of 2 @@ -435,9 +479,11 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) await Promise.all([ @@ -469,9 +515,11 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) await Promise.all([ @@ -502,21 +550,28 @@ describe('batchLinkPlugin', () => { } }) - const mapSubresponse = vi.fn((subResponse: StandardLazyResponse, batchResponse: StandardLazyResponse): StandardLazyResponse => ({ - ...subResponse, - headers: { - ...subResponse.headers, - 'x-mapped': 'true', - 'x-batch-status': `${batchResponse.status}`, - }, - resolveBody: async () => `mapped-${await subResponse.resolveBody()}`, - })) + const mapSubresponse = vi.fn( + ( + subResponse: StandardLazyResponse, + batchResponse: StandardLazyResponse, + ): StandardLazyResponse => ({ + ...subResponse, + headers: { + ...subResponse.headers, + 'x-mapped': 'true', + 'x-batch-status': `${batchResponse.status}`, + }, + resolveBody: async () => `mapped-${String(await subResponse.resolveBody())}`, + }), + ) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - mapSubresponse, - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + mapSubresponse, + }), + ], }) await Promise.all([ @@ -557,9 +612,11 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + }), + ], }) await Promise.all([ @@ -573,14 +630,26 @@ describe('batchLinkPlugin', () => { expect(transport.send).toHaveBeenCalledTimes(3) - const sentGetRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'GET')![0] - expect(extractBatchMessagesFromRequest(sentGetRequest).map(message => message.json.method)).toEqual(['GET', 'GET']) - - const sentQueryRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'QUERY')![0] - expect(extractBatchMessagesFromRequest(sentQueryRequest).map(message => message.json.method)).toEqual(['QUERY', 'QUERY']) - - const sentPostRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'POST')![0] - expect(extractBatchMessagesFromRequest(sentPostRequest).map(message => message.json.method)).toEqual(['PUT', 'PUT']) + const sentGetRequest = vi + .mocked(transport.send) + .mock.calls.find(([request]) => request.method === 'GET')![0] + expect( + extractBatchMessagesFromRequest(sentGetRequest).map((message) => message.json.method), + ).toEqual(['GET', 'GET']) + + const sentQueryRequest = vi + .mocked(transport.send) + .mock.calls.find(([request]) => request.method === 'QUERY')![0] + expect( + extractBatchMessagesFromRequest(sentQueryRequest).map((message) => message.json.method), + ).toEqual(['QUERY', 'QUERY']) + + const sentPostRequest = vi + .mocked(transport.send) + .mock.calls.find(([request]) => request.method === 'POST')![0] + expect( + extractBatchMessagesFromRequest(sentPostRequest).map((message) => message.json.method), + ).toEqual(['PUT', 'PUT']) }) it('aborts grouped batch request when all sub-requests are aborted', async () => { @@ -606,8 +675,12 @@ describe('batchLinkPlugin', () => { const controller2 = new AbortController() 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(link.call(['a'], {}, { context: {}, signal: controller1.signal })).rejects.toThrow( + 'aborted', + ), + expect(link.call(['b'], {}, { context: {}, signal: controller2.signal })).rejects.toThrow( + 'aborted', + ), ]) await sleep(10) @@ -670,7 +743,9 @@ describe('batchLinkPlugin', () => { }) const controller = new AbortController() - const abortedPromise = expect(link.call(['a'], {}, { context: {}, signal: controller.signal })).rejects.toThrow('TEST_ABORT') + const abortedPromise = expect( + link.call(['a'], {}, { context: {}, signal: controller.signal }), + ).rejects.toThrow('TEST_ABORT') const promise2 = link.call(['b'], {}, { context: {} }) const promise3 = link.call(['c'], {}, { context: {} }) @@ -682,7 +757,9 @@ describe('batchLinkPlugin', () => { await expect(promise2).resolves.toBe('result-0') await expect(promise3).resolves.toBe('result-1') expect(transport.send).toHaveBeenCalledTimes(1) - expect(extractBatchMessagesFromRequest(vi.mocked(transport.send).mock.calls[0]![0])).toHaveLength(2) + expect( + extractBatchMessagesFromRequest(vi.mocked(transport.send).mock.calls[0]![0]), + ).toHaveLength(2) }) it('rejects requests aborted while batch options resolve without stalling the rest of the batch', async () => { @@ -690,17 +767,21 @@ describe('batchLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - url: async () => { - await sleep(50) - return '/__batch__' as const - }, - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + url: async () => { + await sleep(50) + return '/__batch__' as const + }, + }), + ], }) const controller = new AbortController() - const abortedPromise = expect(link.call(['a'], {}, { context: {}, signal: controller.signal })).rejects.toThrow('TEST_ABORT') + const abortedPromise = expect( + link.call(['a'], {}, { context: {}, signal: controller.signal }), + ).rejects.toThrow('TEST_ABORT') const promise2 = link.call(['b'], {}, { context: {} }) const promise3 = link.call(['c'], {}, { context: {} }) @@ -711,7 +792,9 @@ describe('batchLinkPlugin', () => { await expect(promise2).resolves.toBe('result-0') await expect(promise3).resolves.toBe('result-1') expect(transport.send).toHaveBeenCalledTimes(1) - expect(extractBatchMessagesFromRequest(vi.mocked(transport.send).mock.calls[0]![0])).toHaveLength(2) + expect( + extractBatchMessagesFromRequest(vi.mocked(transport.send).mock.calls[0]![0]), + ).toHaveLength(2) }) it('rejects requests aborted before the peer sends them without stalling the rest of the batch', async () => { @@ -720,20 +803,24 @@ describe('batchLinkPlugin', () => { const controller = new AbortController() const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - mapSubrequest: ({ request }) => { - if (request.url === '/a') { - // Runs after the peer starts sending this subrequest, before its request message - queueMicrotask(() => controller.abort(new Error('TEST_ABORT'))) - } + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + mapSubrequest: ({ request }) => { + if (request.url === '/a') { + // Runs after the peer starts sending this subrequest, before its request message + queueMicrotask(() => controller.abort(new Error('TEST_ABORT'))) + } - return request - }, - })], + return request + }, + }), + ], }) - const abortedPromise = expect(link.call(['a'], {}, { context: {}, signal: controller.signal })).rejects.toThrow('TEST_ABORT') + const abortedPromise = expect( + link.call(['a'], {}, { context: {}, signal: controller.signal }), + ).rejects.toThrow('TEST_ABORT') const promise2 = link.call(['b'], {}, { context: {} }) const promise3 = link.call(['c'], {}, { context: {} }) @@ -743,7 +830,11 @@ describe('batchLinkPlugin', () => { expect(transport.send).toHaveBeenCalledTimes(1) // The never-sent subrequest adds no message, not even a cancel - expect(extractBatchMessagesFromRequest(vi.mocked(transport.send).mock.calls[0]![0]).map(m => m.kind)).toEqual(['request', 'request']) + expect( + extractBatchMessagesFromRequest(vi.mocked(transport.send).mock.calls[0]![0]).map( + (m) => m.kind, + ), + ).toEqual(['request', 'request']) }) it('sends no batch request when every subrequest is aborted before the peer sends it', async () => { @@ -752,67 +843,82 @@ describe('batchLinkPlugin', () => { const controller = new AbortController() const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - mapSubrequest: ({ request }) => { - queueMicrotask(() => controller.abort(new Error('TEST_ABORT'))) - return request - }, - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + mapSubrequest: ({ request }) => { + queueMicrotask(() => controller.abort(new Error('TEST_ABORT'))) + return request + }, + }), + ], }) await Promise.all([ - expect(link.call(['a'], {}, { context: {}, signal: controller.signal })).rejects.toThrow('TEST_ABORT'), - expect(link.call(['b'], {}, { context: {}, signal: controller.signal })).rejects.toThrow('TEST_ABORT'), + expect(link.call(['a'], {}, { context: {}, signal: controller.signal })).rejects.toThrow( + 'TEST_ABORT', + ), + expect(link.call(['b'], {}, { context: {}, signal: controller.signal })).rejects.toThrow( + 'TEST_ABORT', + ), ]) expect(transport.send).not.toHaveBeenCalled() }) - it.each(['POST', 'GET'] as const)('leaves a subrequest aborted after its request message but before the batch out of the %s batch', async (method) => { - const codec = makeCodec() - const transport = makeTransport() - const controller = new AbortController() + it.each(['POST', 'GET'] as const)( + 'leaves a subrequest aborted after its request message but before the batch out of the %s batch', + async (method) => { + const codec = makeCodec() + const transport = makeTransport() + const controller = new AbortController() - vi.mocked(codec.encodeInput).mockImplementation(async (_input, path, { signal }) => ({ - method, - url: `/${path.join('/')}` as `/${string}`, - headers: {}, - body: undefined, - signal, - })) + vi.mocked(codec.encodeInput).mockImplementation(async (_input, path, { signal }) => ({ + method, + url: `/${path.join('/')}` as `/${string}`, + headers: {}, + body: undefined, + signal, + })) - const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - mapSubrequest: ({ request }) => { - if (request.url !== '/b') { - return request - } - - // Read when the peer starts sending b, after a's request message - return { - ...request, - get body() { - controller.abort(new Error('TEST_ABORT')) - return request.body + const link = new StandardLink(codec, transport, { + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + mapSubrequest: ({ request }) => { + if (request.url !== '/b') { + return request + } + + // Read when the peer starts sending b, after a's request message + return { + ...request, + get body() { + controller.abort(new Error('TEST_ABORT')) + return request.body + }, + } }, - } - }, - })], - }) + }), + ], + }) - const abortedPromise = expect(link.call(['a'], {}, { context: {}, signal: controller.signal })).rejects.toThrow('TEST_ABORT') - const promise2 = link.call(['b'], {}, { context: {} }) + const abortedPromise = expect( + link.call(['a'], {}, { context: {}, signal: controller.signal }), + ).rejects.toThrow('TEST_ABORT') + const promise2 = link.call(['b'], {}, { context: {} }) - await abortedPromise - await expect(promise2).resolves.toBe('result-0') + await abortedPromise + await expect(promise2).resolves.toBe('result-0') - const batchRequest = vi.mocked(transport.send).mock.calls[0]![0] - expect(batchRequest.method).toBe(method) - expect(batchRequest.signal?.aborted).toBe(false) - expect(extractBatchMessagesFromRequest(batchRequest).map(m => [m.kind, m.json?.url])).toEqual([['request', '/b']]) - }) + const batchRequest = vi.mocked(transport.send).mock.calls[0]![0] + expect(batchRequest.method).toBe(method) + expect(batchRequest.signal?.aborted).toBe(false) + expect( + extractBatchMessagesFromRequest(batchRequest).map((m) => [m.kind, m.json?.url]), + ).toEqual([['request', '/b']]) + }, + ) it('keeps the sent batch messages unchanged when a subrequest is aborted after the batch is handed to the transport', async () => { const codec = makeCodec() @@ -829,11 +935,17 @@ describe('batchLinkPlugin', () => { }) await Promise.all([ - expect(link.call(['a'], {}, { context: {}, signal: controller.signal })).rejects.toThrow('TEST_ABORT'), + expect(link.call(['a'], {}, { context: {}, signal: controller.signal })).rejects.toThrow( + 'TEST_ABORT', + ), expect(link.call(['b'], {}, { context: {} })).resolves.toBe('result-1'), ]) - expect(extractBatchMessagesFromRequest(vi.mocked(transport.send).mock.calls[0]![0]).map(m => m.kind)).toEqual(['request', 'request']) + expect( + extractBatchMessagesFromRequest(vi.mocked(transport.send).mock.calls[0]![0]).map( + (m) => m.kind, + ), + ).toEqual(['request', 'request']) }) it('aborts the batch request once every subrequest is aborted, including ones aborted before sending', async () => { @@ -860,9 +972,15 @@ describe('batchLinkPlugin', () => { const controller3 = new AbortController() 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(link.call(['c'], {}, { context: {}, signal: controller3.signal })).rejects.toThrow('aborted'), + expect(link.call(['a'], {}, { context: {}, signal: controller1.signal })).rejects.toThrow( + 'aborted', + ), + expect(link.call(['b'], {}, { context: {}, signal: controller2.signal })).rejects.toThrow( + 'aborted', + ), + expect(link.call(['c'], {}, { context: {}, signal: controller3.signal })).rejects.toThrow( + 'aborted', + ), ]) await sleep(10) @@ -882,9 +1000,13 @@ describe('batchLinkPlugin', () => { const { outputA, outputB, idA, idB, signal, push } = await startStreamingBatch() await push({ kind: 'response', id: idA, json: { body: 'a' } }) - await push({ kind: 'response', id: idB, json: { headers: { 'standard-server': 'event-stream' } } }) + await push({ + kind: 'response', + id: idB, + json: { headers: { 'standard-server': 'event-stream' } }, + }) await expect(outputA).resolves.toBe('a') - const iteratorB = await outputB as AsyncIteratorObject + const iteratorB = (await outputB) as AsyncIteratorObject await iteratorB.return?.() expect(signal.aborted).toBe(true) @@ -893,10 +1015,18 @@ describe('batchLinkPlugin', () => { it('keeps the batch request open after a cancel until every other stream finishes', async () => { const { outputA, outputB, idA, idB, signal, push } = await startStreamingBatch() - await push({ kind: 'response', id: idA, json: { headers: { 'standard-server': 'event-stream' } } }) - await push({ kind: 'response', id: idB, json: { headers: { 'content-type': 'application/octet-stream' } } }) - const iteratorA = await outputA as AsyncIteratorObject - const streamB = await outputB as ReadableStream + await push({ + kind: 'response', + id: idA, + json: { headers: { 'standard-server': 'event-stream' } }, + }) + await push({ + kind: 'response', + id: idB, + json: { headers: { 'content-type': 'application/octet-stream' } }, + }) + const iteratorA = (await outputA) as AsyncIteratorObject + const streamB = (await outputB) as ReadableStream await streamB.cancel() await push({ kind: 'event-stream', id: idA, json: { data: 'a1' } }) @@ -912,10 +1042,18 @@ describe('batchLinkPlugin', () => { it('keeps the batch request open when a subrequest is cancelled only after the server finished it', async () => { const { outputA, outputB, idA, idB, signal, push } = await startStreamingBatch() - await push({ kind: 'response', id: idA, json: { headers: { 'standard-server': 'event-stream' } } }) - await push({ kind: 'response', id: idB, json: { headers: { 'standard-server': 'event-stream' } } }) - const iteratorA = await outputA as AsyncIteratorObject - const iteratorB = await outputB as AsyncIteratorObject + await push({ + kind: 'response', + id: idA, + json: { headers: { 'standard-server': 'event-stream' } }, + }) + await push({ + kind: 'response', + id: idB, + json: { headers: { 'standard-server': 'event-stream' } }, + }) + const iteratorA = (await outputA) as AsyncIteratorObject + const iteratorB = (await outputB) as AsyncIteratorObject // B's event arriving proves A's close was received, though A never reads it await push({ kind: 'event-stream', id: idA, json: { event: 'close' } }) @@ -932,10 +1070,18 @@ describe('batchLinkPlugin', () => { it('keeps the batch request open when a cancelled stream also finishes on the server before the others', async () => { const { outputA, outputB, idA, idB, signal, push } = await startStreamingBatch() - await push({ kind: 'response', id: idA, json: { headers: { 'standard-server': 'event-stream' } } }) - await push({ kind: 'response', id: idB, json: { headers: { 'standard-server': 'event-stream' } } }) - const iteratorA = await outputA as AsyncIteratorObject - const iteratorB = await outputB as AsyncIteratorObject + await push({ + kind: 'response', + id: idA, + json: { headers: { 'standard-server': 'event-stream' } }, + }) + await push({ + kind: 'response', + id: idB, + json: { headers: { 'standard-server': 'event-stream' } }, + }) + const iteratorA = (await outputA) as AsyncIteratorObject + const iteratorB = (await outputB) as AsyncIteratorObject await iteratorA.return?.() await push({ kind: 'event-stream', id: idA, json: { event: 'close' } }) @@ -948,10 +1094,18 @@ describe('batchLinkPlugin', () => { it('treats a server cancel as the end of a subrequest, but not a stream/cancel', async () => { const { outputA, outputB, idA, idB, signal, push } = await startStreamingBatch() - await push({ kind: 'response', id: idA, json: { headers: { 'standard-server': 'event-stream' } } }) - await push({ kind: 'response', id: idB, json: { headers: { 'standard-server': 'event-stream' } } }) - const iteratorA = await outputA as AsyncIteratorObject - const iteratorB = await outputB as AsyncIteratorObject + await push({ + kind: 'response', + id: idA, + json: { headers: { 'standard-server': 'event-stream' } }, + }) + await push({ + kind: 'response', + id: idB, + json: { headers: { 'standard-server': 'event-stream' } }, + }) + const iteratorA = (await outputA) as AsyncIteratorObject + const iteratorB = (await outputB) as AsyncIteratorObject await iteratorB.return?.() await push({ kind: 'stream/cancel', id: idA }) @@ -1154,8 +1308,12 @@ describe('batchLinkPlugin', () => { }) 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'), + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response format', + ), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response format', + ), ]) }) @@ -1174,7 +1332,9 @@ describe('batchLinkPlugin', () => { return { status: 207, headers: {}, - resolveBody: async () => [{ kind: 'response', id, json: { status: 200, headers: {}, body: 'result' } }], + resolveBody: async () => [ + { kind: 'response', id, json: { status: 200, headers: {}, body: 'result' } }, + ], } }) @@ -1183,8 +1343,12 @@ describe('batchLinkPlugin', () => { }) 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'), + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response format', + ), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response format', + ), ]) }) @@ -1198,12 +1362,13 @@ describe('batchLinkPlugin', () => { return { status: 207, headers: {}, - resolveBody: async () => messages.map(message => ({ - kind: 'response', - id: message.id, - json: { status: 200, headers: {} }, - binary: {}, - })), + resolveBody: async () => + messages.map((message) => ({ + kind: 'response', + id: message.id, + json: { status: 200, headers: {} }, + binary: {}, + })), } }) @@ -1212,8 +1377,12 @@ describe('batchLinkPlugin', () => { }) 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'), + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response format', + ), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response format', + ), ]) }) @@ -1234,8 +1403,12 @@ describe('batchLinkPlugin', () => { }) 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.'), + 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.', + ), ]) }) @@ -1256,8 +1429,12 @@ describe('batchLinkPlugin', () => { }) 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.'), + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response: incomplete message.', + ), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response: incomplete message.', + ), ]) }) @@ -1280,8 +1457,12 @@ describe('batchLinkPlugin', () => { }) 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.'), + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response: invalid message.', + ), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response: invalid message.', + ), ]) }) @@ -1293,12 +1474,13 @@ describe('batchLinkPlugin', () => { return { status: 207, headers: {}, - resolveBody: async () => new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([1, 2, 3])) - controller.close() - }, - }), + resolveBody: async () => + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])) + controller.close() + }, + }), } }) @@ -1307,8 +1489,12 @@ describe('batchLinkPlugin', () => { }) 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.'), + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow( + 'Batch response is incomplete.', + ), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow( + 'Batch response is incomplete.', + ), ]) }) @@ -1320,12 +1506,13 @@ describe('batchLinkPlugin', () => { return { status: 207, headers: {}, - resolveBody: async () => new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('MALFORMED')) - controller.close() - }, - }), + resolveBody: async () => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('MALFORMED')) + controller.close() + }, + }), } }) @@ -1334,8 +1521,12 @@ describe('batchLinkPlugin', () => { }) 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.'), + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow( + 'Batch response is incomplete.', + ), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow( + 'Batch response is incomplete.', + ), ]) }) @@ -1349,12 +1540,13 @@ describe('batchLinkPlugin', () => { return { status: 207, headers: {}, - resolveBody: async () => new ReadableStream({ - start(controller) { - controller.enqueue(bytes) - controller.close() - }, - }), + resolveBody: async () => + new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }), } }) @@ -1363,8 +1555,12 @@ describe('batchLinkPlugin', () => { }) 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.'), + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response: invalid message.', + ), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow( + 'Invalid batch response: invalid message.', + ), ]) }) }) @@ -1440,22 +1636,32 @@ describe('batchLinkPlugin', () => { }) describe('batch option failures', () => { - it.each(['maxSize', 'url', 'headers', 'mapSubrequest'] as const)('rejects every subrequest when %s throws', async (option) => { - const error = new Error('option failed') - const codec = makeCodec() - const transport = makeTransport() + it.each(['maxSize', 'url', 'headers', 'mapSubrequest'] as const)( + 'rejects every subrequest when %s throws', + async (option) => { + const error = new Error('option failed') + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + [option]: () => { + throw error + }, + }), + ], + }) - const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ groups: [defaultGroup], [option]: () => { throw error } })], - }) + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).rejects.toBe(error), + expect(link.call(['b'], {}, { context: {} })).rejects.toBe(error), + ]) - await Promise.all([ - expect(link.call(['a'], {}, { context: {} })).rejects.toBe(error), - expect(link.call(['b'], {}, { context: {} })).rejects.toBe(error), - ]) - - expect(transport.send).not.toHaveBeenCalled() - }) + expect(transport.send).not.toHaveBeenCalled() + }, + ) it('rejects every subrequest when an option throws for a batch split by maxUrlLength', async () => { const error = new Error('option failed') @@ -1470,16 +1676,25 @@ describe('batchLinkPlugin', () => { })) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - maxUrlLength: 1, - url: vi.fn().mockReturnValueOnce('/__batch__').mockImplementation(() => { throw error }), - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + maxUrlLength: 1, + url: vi + .fn() + .mockReturnValueOnce('/__batch__') + .mockImplementation(() => { + throw error + }), + }), + ], }) - await Promise.all(['a', 'b', 'c', 'd'].map(path => - expect(link.call([path], {}, { context: {} })).rejects.toBe(error), - )) + await Promise.all( + ['a', 'b', 'c', 'd'].map((path) => + expect(link.call([path], {}, { context: {} })).rejects.toBe(error), + ), + ) expect(transport.send).not.toHaveBeenCalled() }) @@ -1494,10 +1709,17 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - mapSubresponse: vi.fn().mockImplementationOnce(subResponse => subResponse).mockImplementation(() => { throw error }), - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + mapSubresponse: vi + .fn() + .mockImplementationOnce((subResponse) => subResponse) + .mockImplementation(() => { + throw error + }), + }), + ], }) await Promise.all([ @@ -1521,10 +1743,12 @@ describe('batchLinkPlugin', () => { })) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - maxUrlLength: 1, - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + maxUrlLength: 1, + }), + ], }) await Promise.all([ @@ -1549,10 +1773,12 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - url: () => '/custom/__batch__?existing=1#anchor', - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + url: () => '/custom/__batch__?existing=1#anchor', + }), + ], }) await Promise.all([ @@ -1582,10 +1808,12 @@ describe('batchLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - url: () => '/custom-no-hash/__batch__?existing=1', - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + url: () => '/custom-no-hash/__batch__?existing=1', + }), + ], }) await Promise.all([ @@ -1628,16 +1856,18 @@ describe('batchLinkPlugin', () => { } } - it('sends a request sent alone in its caller\'s async context', async () => { + it("sends a request sent alone in its caller's async context", async () => { const transport = makeUserTransport() const link = new StandardLink(makeUserCodec(), transport, { plugins: [new BatchLinkPlugin({ groups: [defaultGroup] })], }) - await expect(Promise.all([ - storage.run('alice', () => link.call(['get'], {}, { context: {} })), - storage.run('bob', () => link.call(['post'], {}, { context: {} })), - ])).resolves.toEqual(['alice', 'bob']) + await expect( + Promise.all([ + storage.run('alice', () => link.call(['get'], {}, { context: {} })), + storage.run('bob', () => link.call(['post'], {}, { context: {} })), + ]), + ).resolves.toEqual(['alice', 'bob']) expect(transport.send).toHaveBeenCalledTimes(2) }) @@ -1645,33 +1875,44 @@ describe('batchLinkPlugin', () => { it.each([ ['maxSize', { maxSize: 1 }], ['maxUrlLength', { maxUrlLength: 1 }], - ] as const)('sends each part of a batch split by %s in the async context of its first request', async (_, options) => { - const transport = makeUserTransport() - const link = new StandardLink(makeUserCodec(), transport, { - plugins: [new BatchLinkPlugin({ groups: [defaultGroup], ...options })], - }) + ] as const)( + 'sends each part of a batch split by %s in the async context of its first request', + async (_, options) => { + const transport = makeUserTransport() + const link = new StandardLink(makeUserCodec(), transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], ...options })], + }) - const users = ['alice', 'bob', 'carol', 'dave'] - await expect(Promise.all(users.map(user => - storage.run(user, () => link.call(['get'], {}, { context: {} })), - ))).resolves.toEqual(users) + const users = ['alice', 'bob', 'carol', 'dave'] + await expect( + Promise.all( + users.map((user) => storage.run(user, () => link.call(['get'], {}, { context: {} }))), + ), + ).resolves.toEqual(users) - expect(transport.send).toHaveBeenCalledTimes(4) - }) + expect(transport.send).toHaveBeenCalledTimes(4) + }, + ) it('batches only requests with the same scope', async () => { const transport = makeUserTransport() const link = new StandardLink(makeUserCodec(), transport, { - plugins: [new BatchLinkPlugin({ - groups: [defaultGroup], - scope: () => storage.getStore(), - headers: () => ({ 'x-user': storage.getStore() }), - })], + plugins: [ + new BatchLinkPlugin({ + groups: [defaultGroup], + scope: () => storage.getStore(), + headers: () => ({ 'x-user': storage.getStore() }), + }), + ], }) - await expect(Promise.all(['alice', 'bob', 'alice', 'bob'].map(user => - storage.run(user, () => link.call(['get'], {}, { context: {} })), - ))).resolves.toEqual(['alice', 'bob', 'alice', 'bob']) + await expect( + Promise.all( + ['alice', 'bob', 'alice', 'bob'].map((user) => + storage.run(user, () => link.call(['get'], {}, { context: {} })), + ), + ), + ).resolves.toEqual(['alice', 'bob', 'alice', 'bob']) expect(transport.send).toHaveBeenCalledTimes(2) expect(vi.mocked(transport.send).mock.calls.map(([request]) => request.headers)).toEqual([ @@ -1686,10 +1927,12 @@ describe('batchLinkPlugin', () => { plugins: [new BatchLinkPlugin({ groups: [defaultGroup] })], }) - await expect(Promise.all([ - storage.run('alice', () => link.call(['get'], {}, { context: {} })), - storage.run('bob', () => link.call(['get'], {}, { context: {} })), - ])).resolves.toEqual(['alice', 'alice']) + await expect( + Promise.all([ + storage.run('alice', () => link.call(['get'], {}, { context: {} })), + storage.run('bob', () => link.call(['get'], {}, { context: {} })), + ]), + ).resolves.toEqual(['alice', 'alice']) expect(transport.send).toHaveBeenCalledTimes(1) }) diff --git a/packages/client/src/plugins/batch.ts b/packages/client/src/plugins/batch.ts index f71de9ebcd..873a2e5f99 100644 --- a/packages/client/src/plugins/batch.ts +++ b/packages/client/src/plugins/batch.ts @@ -1,11 +1,39 @@ import type { InterceptorOptions, Promisable, Value } from '@orpc/shared' -import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardUrl } from '@standard-server/core' -import type { ClientPeerSendMessage, ServerPeerSendMessage } from '@standard-server/peer' -import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor, StandardLinkTransportInterceptorOptions } from '../adapters/standard' -import type { ClientContext } from '../types' -import { captureAsyncContext, defer, isAsyncIteratorObject, loadBytes, once, promiseWithResolvers, safeEncodeURIComponent, splitInHalf, stringifyJSON, toArray, value } from '@orpc/shared' +import { + captureAsyncContext, + defer, + isAsyncIteratorObject, + loadBytes, + once, + promiseWithResolvers, + safeEncodeURIComponent, + splitInHalf, + stringifyJSON, + toArray, + value, +} from '@orpc/shared' +import type { + StandardHeaders, + StandardLazyResponse, + StandardRequest, + StandardUrl, +} from '@standard-server/core' import { parseStandardUrl } from '@standard-server/core' -import { ClientPeer, decodePeerMessage, isPeerMessage, isServerPeerSendMessage } from '@standard-server/peer' +import type { ClientPeerSendMessage, ServerPeerSendMessage } from '@standard-server/peer' +import { + ClientPeer, + decodePeerMessage, + isPeerMessage, + isServerPeerSendMessage, +} from '@standard-server/peer' + +import type { + StandardLinkOptions, + StandardLinkPlugin, + StandardLinkTransportInterceptor, + StandardLinkTransportInterceptorOptions, +} from '../adapters/standard/index.ts' +import type { ClientContext } from '../types.ts' /** * Header that marks a request as a batch, set by the Batch Link Plugin and detected by the Batch Handler Plugin. @@ -39,14 +67,32 @@ export interface BatchLinkPluginGroup { /** * The client context applied to requests in this batch group for the remainder of the link chain. */ - context: Value, ...StandardLinkTransportInterceptorOptions[]]]> + context: Value< + T, + [ + items: [ + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ], + ] + > /** * The path segments applied to requests in this batch group for the remainder of the link chain. * * @default [] */ - path?: Value, ...StandardLinkTransportInterceptorOptions[]]]> | undefined + path?: + | Value< + string[], + [ + items: [ + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ], + ] + > + | undefined } export interface BatchLinkPluginOptions { @@ -74,7 +120,17 @@ export interface BatchLinkPluginOptions { * @default DEFAULT_BATCH_MAX_SIZE (10) * @see {@link https://orpc.dev/docs/plugins/batch#max-size | Batch Plugin - Max Size} */ - maxSize?: Value, [subOptionsList: [StandardLinkTransportInterceptorOptions, ...StandardLinkTransportInterceptorOptions[]]]> | undefined + maxSize?: + | Value< + Promisable, + [ + subOptionsList: [ + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ], + ] + > + | undefined /** * How long (in ms) to wait for more requests before sending the batch, @@ -95,14 +151,34 @@ export interface BatchLinkPluginOptions { * @default 'buffered' * @see {@link https://orpc.dev/docs/plugins/batch#response-modes | Batch Plugin - Response Modes} */ - mode?: Value, [subOptionsList: [StandardLinkTransportInterceptorOptions, ...StandardLinkTransportInterceptorOptions[]]]> | undefined + mode?: + | Value< + Promisable, + [ + subOptionsList: [ + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ], + ] + > + | undefined /** * URL for the batch request. * * @default URL of the first subrequest in the batch + '/__batch__' */ - url?: Value, [subOptionsList: [StandardLinkTransportInterceptorOptions, ...StandardLinkTransportInterceptorOptions[]]]> | undefined + url?: + | Value< + Promisable, + [ + subOptionsList: [ + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ], + ] + > + | undefined /** * The maximum length of the URL that runtime supports, @@ -112,28 +188,59 @@ export interface BatchLinkPluginOptions { * * @default 2083 */ - maxUrlLength?: Value, [subOptionsList: [StandardLinkTransportInterceptorOptions, ...StandardLinkTransportInterceptorOptions[]]]> | undefined + maxUrlLength?: + | Value< + Promisable, + [ + subOptionsList: [ + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ], + ] + > + | undefined /** * Headers used for the batch request. * * @default Common headers among all subrequests in the batch. */ - headers?: Value, [subOptionsList: [StandardLinkTransportInterceptorOptions, ...StandardLinkTransportInterceptorOptions[]]]> | undefined + headers?: + | Value< + Promisable, + [ + subOptionsList: [ + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ], + ] + > + | undefined /** * Map each subrequest in the batch before it is sent. * * @default Removes headers that are duplicated with the batch headers */ - mapSubrequest?: ((subOptions: StandardLinkTransportInterceptorOptions, partialBatchRequest: Pick) => StandardRequest) | undefined + mapSubrequest?: + | (( + subOptions: StandardLinkTransportInterceptorOptions, + partialBatchRequest: Pick, + ) => StandardRequest) + | undefined /** * Maps each subresponse before returning the final response. * * @default Low-priority merges headers from the batch response into each subresponse. */ - mapSubresponse?: ((subResponse: StandardLazyResponse, batchResponse: StandardLazyResponse, subOptions: StandardLinkTransportInterceptorOptions) => StandardLazyResponse) | undefined + mapSubresponse?: + | (( + subResponse: StandardLazyResponse, + batchResponse: StandardLazyResponse, + subOptions: StandardLinkTransportInterceptorOptions, + ) => StandardLazyResponse) + | undefined } /** @@ -160,7 +267,8 @@ export class BatchLinkPlugin implements StandardLinkPlu private readonly mapSubrequest: Exclude['mapSubrequest'], undefined> private readonly mapSubresponse: Exclude['mapSubresponse'], undefined> - private readonly queue: Map, BatchLinkPluginItem[]>> = new Map() + private readonly queue: Map, BatchLinkPluginItem[]>> = + new Map() constructor(options: NoInfer>) { this.groups = options.groups @@ -169,66 +277,76 @@ export class BatchLinkPlugin implements StandardLinkPlu this.maxSize = options.maxSize ?? DEFAULT_BATCH_MAX_SIZE this.wait = options.wait ?? 0 this.mode = options.mode ?? 'buffered' - this.batchUrl = options.url ?? ((options) => { - const [pathname] = parseStandardUrl(options[0].request.url) - return `${pathname}/__batch__` - }) + 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) { + 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 of Object.keys(headers)) { + const value = headers[key] + if (headersList.every((h) => h[key] === value)) { + commonHeaders[key] = value + } + } + } + + return commonHeaders + }) + this.mapSubrequest = + options.mapSubrequest ?? + (({ request }, { headers }) => { + const subHeaders = { ...request.headers } for (const key of Object.keys(headers)) { const value = headers[key] - if (headersList.every(h => h[key] === value)) { - commonHeaders[key] = value + if (subHeaders[key] === value) { + subHeaders[key] = undefined } } - } - return commonHeaders - }) - this.mapSubrequest = options.mapSubrequest ?? (({ request }, { headers }) => { - const subHeaders = { ...request.headers } - for (const key of Object.keys(headers)) { - const value = headers[key] - if (subHeaders[key] === value) { - subHeaders[key] = undefined + return { + ...request, + headers: subHeaders, } - } - - return { - ...request, - headers: subHeaders, - } - }) - this.mapSubresponse = options.mapSubresponse ?? ((subResponse, batchResponse) => { - return { - ...subResponse, - headers: { - ...batchResponse.headers, // low-priority - ...subResponse.headers, - }, - } - }) + }) + this.mapSubresponse = + options.mapSubresponse ?? + ((subResponse, batchResponse) => { + return { + ...subResponse, + headers: { + ...batchResponse.headers, // low-priority + ...subResponse.headers, + }, + } + }) } init(options: StandardLinkOptions): StandardLinkOptions { - const transportInterceptor: StandardLinkTransportInterceptor = async (interceptorOptions) => { + const transportInterceptor: StandardLinkTransportInterceptor = async ( + interceptorOptions, + ) => { const { body, signal } = interceptorOptions.request if ( - body instanceof Blob - || body instanceof FormData - || body instanceof ReadableStream - || isAsyncIteratorObject(body) - || signal?.aborted - || !value(this.filter, interceptorOptions) + body instanceof Blob || + body instanceof FormData || + body instanceof ReadableStream || + isAsyncIteratorObject(body) || + signal?.aborted || + !value(this.filter, interceptorOptions) ) { return interceptorOptions.next() } - const group = this.groups.find(group => value(group.condition, interceptorOptions)) + const group = this.groups.find((group) => value(group.condition, interceptorOptions)) if (!group) { return interceptorOptions.next() @@ -272,11 +390,13 @@ export class BatchLinkPlugin implements StandardLinkPlu for (const [group, items] of groups) { const getItems = items.filter(([options]) => options.request.method === 'GET') const queryItems = items.filter(([options]) => options.request.method === 'QUERY') - const unsafeItems = items.filter(([options]) => options.request.method !== 'GET' && options.request.method !== 'QUERY') + const unsafeItems = items.filter( + ([options]) => options.request.method !== 'GET' && options.request.method !== 'QUERY', + ) - this.executeBatchInOwnContext('GET', group, getItems) - this.executeBatchInOwnContext('QUERY', group, queryItems) - this.executeBatchInOwnContext('POST', group, unsafeItems) + void this.executeBatchInOwnContext('GET', group, getItems) + void this.executeBatchInOwnContext('QUERY', group, queryItems) + void this.executeBatchInOwnContext('POST', group, unsafeItems) } } } @@ -307,8 +427,14 @@ export class BatchLinkPlugin implements StandardLinkPlu } const subOptionsList = groupItems.map(([options]) => options) as [ - InterceptorOptions, Promise>, - ...InterceptorOptions, Promise>[], + InterceptorOptions< + StandardLinkTransportInterceptorOptions, + Promise + >, + ...InterceptorOptions< + StandardLinkTransportInterceptorOptions, + Promise + >[], ] const maxSize = await value(this.maxSize, subOptionsList) @@ -330,7 +456,9 @@ export class BatchLinkPlugin implements StandardLinkPlu const controller = new AbortController() const pendingMessages: ClientPeerSendMessage[] = [] - const subrequests = groupItems.map(([subOptions]) => this.mapSubrequest(subOptions, { url, headers })) + const subrequests = groupItems.map(([subOptions]) => + this.mapSubrequest(subOptions, { url, headers }), + ) let batchResponse: StandardLazyResponse let markRequestSent: (() => void) | undefined const openRequestIds = new Set() @@ -349,8 +477,7 @@ export class BatchLinkPlugin implements StandardLinkPlu if (message.kind === 'request') { openRequestIds.add(message.id) markRequestSent?.() - } - else if (message.kind === 'cancel' && openRequestIds.delete(message.id) && isBatchSent) { + } else if (message.kind === 'cancel' && openRequestIds.delete(message.id) && isBatchSent) { cancelledRunningRequestIds.add(message.id) abortIfOnlyCancelledRemain() } @@ -365,9 +492,11 @@ export class BatchLinkPlugin implements StandardLinkPlu const sent = promiseWithResolvers() markRequestSent = sent.resolve - peer + void peer .request(subrequests[index]!) - .then(subResponse => resolve(this.mapSubresponse(subResponse, batchResponse, subOptions))) + .then((subResponse) => + resolve(this.mapSubresponse(subResponse, batchResponse, subOptions)), + ) .catch((error) => { if (!suppressErrorFromCurrentBatch) { reject(error) @@ -388,7 +517,7 @@ export class BatchLinkPlugin implements StandardLinkPlu * Leave out subrequests aborted before the batch is sent: the server counts their request and cancel * against its batch size limit and would still run calls the client already rejected. */ - const outgoingMessages = pendingMessages.filter(message => openRequestIds.has(message.id)) + const outgoingMessages = pendingMessages.filter((message) => openRequestIds.has(message.id)) try { const request: StandardRequest = { @@ -420,8 +549,7 @@ export class BatchLinkPlugin implements StandardLinkPlu } request.url = newUrl - } - else { + } else { request.body = outgoingMessages } @@ -453,15 +581,16 @@ export class BatchLinkPlugin implements StandardLinkPlu const body = await batchResponse.resolveBody() - if (Array.isArray(body) && body.every((v: unknown) => isPeerMessage(v) && isServerPeerSendMessage(v))) { + if ( + Array.isArray(body) && + body.every((v: unknown) => isPeerMessage(v) && isServerPeerSendMessage(v)) + ) { for (const message of body) { await peer.message(message) } - } - else if (body instanceof Blob) { + } else if (body instanceof Blob) { await decodeLengthPrefixedBlob(body, peer) - } - else if (body instanceof ReadableStream) { + } else if (body instanceof ReadableStream) { await decodeLengthPrefixedStream(body, async (message) => { await peer.message(message) @@ -471,25 +600,25 @@ export class BatchLinkPlugin implements StandardLinkPlu abortIfOnlyCancelledRemain() } }) - } - else { + } else { throw new TypeError('Invalid batch response format.') } await peer.close(new TypeError('Batch response is incomplete.')) - } - catch (error) { + } catch (error) { await peer.close(error) } - } - catch (error) { + } catch (error) { groupItems.forEach(([, , reject]) => reject(error)) } } } type BatchLinkPluginItem = [ - options: InterceptorOptions, Promise>, + options: InterceptorOptions< + StandardLinkTransportInterceptorOptions, + Promise + >, resolve: (response: StandardLazyResponse) => void, reject: (e: unknown) => void, runInOwnContext: ReturnType, @@ -502,9 +631,12 @@ function isLastServerMessage(message: ServerPeerSendMessage): boolean { switch (message.kind) { case 'response': // A body-less response with a content type or body hint is followed by stream messages - return message.json.body !== undefined - || message.binary !== undefined - || (message.json.headers?.['content-type'] === undefined && message.json.headers?.['standard-server'] === undefined) + return ( + message.json.body !== undefined || + message.binary !== undefined || + (message.json.headers?.['content-type'] === undefined && + message.json.headers?.['standard-server'] === undefined) + ) case 'event-stream': return message.json.event === 'close' || message.json.event === 'error' case 'octet-stream': @@ -545,7 +677,10 @@ async function decodeLengthPrefixedBlob(blob: Blob, peer: ClientPeer): Promise, receive: (message: ServerPeerSendMessage) => Promise): Promise { +async function decodeLengthPrefixedStream( + stream: ReadableStream, + receive: (message: ServerPeerSendMessage) => Promise, +): Promise { const reader = stream.getReader() let buffer = new Uint8Array(0) @@ -590,8 +725,7 @@ async function decodeLengthPrefixedStream(stream: ReadableStream, re break } } - } - finally { + } finally { reader.releaseLock() } } diff --git a/packages/client/src/plugins/cookie-jar.test.ts b/packages/client/src/plugins/cookie-jar.test.ts index 6ec0aba224..2b6750c4ed 100644 --- a/packages/client/src/plugins/cookie-jar.test.ts +++ b/packages/client/src/plugins/cookie-jar.test.ts @@ -1,26 +1,42 @@ import type { StandardHeaders, StandardLazyResponse } from '@standard-server/core' -import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' -import type { CookieJarLinkPluginOptions } from './cookie-jar' -import { RPCLink } from '../adapters/fetch' -import { StandardLink } from '../adapters/standard' -import { createORPCClient } from '../client' -import { BatchLinkPlugin } from './batch' -import { CookieJar, CookieJarLinkPlugin } from './cookie-jar' + +import { RPCLink } from '../adapters/fetch/index.ts' +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard/index.ts' +import { StandardLink } from '../adapters/standard/index.ts' +import { createORPCClient } from '../client.ts' +import { BatchLinkPlugin } from './batch.ts' +import type { CookieJarLinkPluginOptions } from './cookie-jar.ts' +import { CookieJar, CookieJarLinkPlugin } from './cookie-jar.ts' const origin = 'https://api.example.com' -function createTransport(...responses: Partial[]): StandardLinkTransport { +function createTransport( + ...responses: Partial[] +): StandardLinkTransport { let index = 0 return { - send: vi.fn(async () => ({ status: 200, headers: {}, resolveBody: async () => 'success', ...responses[index++] })), + send: vi.fn(async () => ({ + status: 200, + headers: {}, + resolveBody: async () => 'success', + ...responses[index++], + })), } } -function createLink(options: Partial> = {}, transport = createTransport(), headers: StandardHeaders = {}) { +function createLink( + options: Partial> = {}, + transport = createTransport(), + headers: StandardHeaders = {}, +) { const codec: StandardLinkCodec = { - encodeInput: async (_input, path) => ({ method: 'POST', url: `/rpc/${path.join('/')}`, headers }), - decodeResponse: async response => ({ kind: 'output', output: await response.resolveBody() }), + encodeInput: async (_input, path) => ({ + method: 'POST', + url: `/rpc/${path.join('/')}`, + headers, + }), + decodeResponse: async (response) => ({ kind: 'output', output: await response.resolveBody() }), } return new StandardLink(codec, transport, { @@ -33,7 +49,7 @@ function sentCookie(transport: StandardLinkTransport, call: number) { } function pairs(jar: CookieJar, url?: string) { - return jar.getCookies(url).map(cookie => `${cookie.name}=${cookie.value}`) + return jar.getCookies(url).map((cookie) => `${cookie.name}=${cookie.value}`) } describe('cookieJarLinkPlugin', () => { @@ -56,40 +72,59 @@ describe('cookieJarLinkPlugin', () => { }) it('matches cookies against the origin', async () => { - const transport = createTransport({ headers: { 'set-cookie': ['host=1; Path=/', 'secure=1; Secure; Path=/'] } }) + const transport = createTransport({ + headers: { 'set-cookie': ['host=1; Path=/', 'secure=1; Secure; Path=/'] }, + }) const getOrigin = vi.fn(({ context }: { context: { origin: string } }) => context.origin) const link = createLink({ origin: getOrigin }, transport) - for (const requestOrigin of ['https://api.example.com/', 'https://api.example.com', 'http://api.example.com', 'https://other.com']) { + for (const requestOrigin of [ + 'https://api.example.com/', + 'https://api.example.com', + 'http://api.example.com', + 'https://other.com', + ]) { await link.call(['ping'], 'input', { context: { origin: requestOrigin } }) } - expect(getOrigin).toHaveBeenCalledWith(expect.objectContaining({ context: { origin: 'https://api.example.com/' }, path: ['ping'] }), ['ping']) + expect(getOrigin).toHaveBeenCalledWith( + expect.objectContaining({ context: { origin: 'https://api.example.com/' }, path: ['ping'] }), + ['ping'], + ) expect(sentCookie(transport, 1)).toBe('host=1; secure=1') expect(sentCookie(transport, 2)).toBe('host=1') expect(sentCookie(transport, 3)).toBeUndefined() }) - it.each([undefined, '', '/api'])('does nothing when the origin resolves to %j', async (relativeOrigin) => { - const jar = new CookieJar() - jar.setCookie('session=abc; Path=/', origin) - const transport = createTransport({ headers: { 'set-cookie': 'theme=dark; Path=/' } }) + it.each([undefined, '', '/api'])( + 'does nothing when the origin resolves to %j', + async (relativeOrigin) => { + const jar = new CookieJar() + jar.setCookie('session=abc; Path=/', origin) + const transport = createTransport({ headers: { 'set-cookie': 'theme=dark; Path=/' } }) - await createLink({ jar, origin: relativeOrigin }, transport).call(['ping'], 'input', { context: {} }) + await createLink({ jar, origin: relativeOrigin }, transport).call(['ping'], 'input', { + context: {}, + }) - expect(sentCookie(transport, 0)).toBeUndefined() - expect(pairs(jar)).toEqual(['session=abc']) - }) + expect(sentCookie(transport, 0)).toBeUndefined() + expect(pairs(jar)).toEqual(['session=abc']) + }, + ) it.each([ ['', [], ''], [['a=1', 'b=2'], [], ['a=1', 'b=2']], [undefined, ['a=1', 'flag'], 'a=1; flag'], - ['session=explicit; lang=en', ['session=jar', 'theme=dark'], 'session=explicit; lang=en; theme=dark'], + [ + 'session=explicit; lang=en', + ['session=jar', 'theme=dark'], + 'session=explicit; lang=en; theme=dark', + ], [['a=1', ' ', 'b=2'], ['a=jar', 'c=jar'], 'a=1; b=2; c=jar'], ])('sends cookie header %j with jar cookies %j as %j', async (cookie, jarCookies, expected) => { const jar = new CookieJar() - jarCookies.forEach(jarCookie => jar.setCookie(`${jarCookie}; Path=/`, origin)) + jarCookies.forEach((jarCookie) => jar.setCookie(`${jarCookie}; Path=/`, origin)) const transport = createTransport() await createLink({ jar }, transport, { cookie }).call(['ping'], 'input', { context: {} }) @@ -109,7 +144,9 @@ describe('cookieJarLinkPlugin', () => { await link.call(['ping'], 'input', { context: { user: 'alice' } }) await link.call(['ping'], 'input', { context: { user: 'bob' } }) - expect(jar).toHaveBeenCalledWith(expect.objectContaining({ context: { user: 'alice' }, path: ['ping'] })) + expect(jar).toHaveBeenCalledWith( + expect.objectContaining({ context: { user: 'alice' }, path: ['ping'] }), + ) expect(sentCookie(transport, 0)).toBe('session=alice') expect(sentCookie(transport, 1)).toBe('session=bob') expect(pairs(jars.alice)).toEqual(['session=alice']) @@ -122,26 +159,39 @@ describe('cookieJarLinkPlugin', () => { const messages = JSON.parse(init.body as string) as { id: string }[] return Response.json( - messages.map(({ id }) => ({ id, kind: 'response', json: { status: 200, body: { json: 'pong' } } })), - { headers: [['set-cookie', 'session=def; Path=/'], ['set-cookie', 'theme=dark; Path=/']] }, + messages.map(({ id }) => ({ + id, + kind: 'response', + json: { status: 200, body: { json: 'pong' } }, + })), + { + headers: [ + ['set-cookie', 'session=def; Path=/'], + ['set-cookie', 'theme=dark; Path=/'], + ], + }, ) }) - const client = createORPCClient(new RPCLink<{ cookie?: string }>({ - fetch, - url: '/rpc', - origin, - headers: ({ context }) => ({ cookie: context.cookie }), - plugins: [ - new CookieJarLinkPlugin({ jar, origin }), - new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }] }), - ], - })) + const client = createORPCClient( + new RPCLink<{ cookie?: string }>({ + fetch, + url: '/rpc', + origin, + headers: ({ context }) => ({ cookie: context.cookie }), + plugins: [ + new CookieJarLinkPlugin({ jar, origin }), + new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }] }), + ], + }), + ) const sent = () => { expect(fetch).toHaveBeenCalledTimes(1) const [, init] = fetch.mock.calls[0]! - const subrequests = JSON.parse(init.body as string) as { json: { url: string, headers?: Record } }[] + const subrequests = JSON.parse(init.body as string) as { + json: { url: string; headers?: Record } + }[] return { cookie: new Headers(init.headers).get('cookie'), @@ -161,7 +211,10 @@ describe('cookieJarLinkPlugin', () => { expect(sent()).toEqual({ cookie: 'session=abc', - subrequestCookies: [['/rpc/a', undefined], ['/rpc/b', undefined]], + subrequestCookies: [ + ['/rpc/a', undefined], + ['/rpc/b', undefined], + ], }) expect(pairs(jar)).toEqual(['session=def', 'theme=dark']) }) @@ -207,15 +260,17 @@ describe('cookieJar', () => { it('stores a cookie and returns it for the same site', () => { jar.setCookie('session=abc; Path=/; HttpOnly', 'https://example.com/rpc/login') - expect(jar.getCookies('https://example.com/rpc/me')).toEqual([{ - name: 'session', - value: 'abc', - domain: 'example.com', - hostOnly: true, - path: '/', - secure: false, - expires: undefined, - }]) + expect(jar.getCookies('https://example.com/rpc/me')).toEqual([ + { + name: 'session', + value: 'abc', + domain: 'example.com', + hostOnly: true, + path: '/', + secure: false, + expires: undefined, + }, + ]) expect(jar.getCookies('https://other.com/rpc/me')).toEqual([]) }) @@ -235,11 +290,14 @@ describe('cookieJar', () => { expect(jar.getCookies()).toEqual([]) }) - it.each(['a=b\nc', 'a\u007F=b', 'a=b; Path=/\u0000'])('ignores a cookie with control characters: %j', (setCookie) => { - jar.setCookie(setCookie, site) + it.each(['a=b\nc', 'a\u007F=b', 'a=b; Path=/\u0000'])( + 'ignores a cookie with control characters: %j', + (setCookie) => { + jar.setCookie(setCookie, site) - expect(jar.getCookies()).toEqual([]) - }) + expect(jar.getCookies()).toEqual([]) + }, + ) it('replaces a cookie with the same name, domain, and path in place', () => { jar.setCookie('a=1; Path=/', site) @@ -301,14 +359,17 @@ describe('cookieJar', () => { expect(jar.getCookies('https://example.com/')).toHaveLength(0) }) - it.each(['example.com', '.example.com', 'EXAMPLE.com'])('sends a cookie with Domain=%s to the domain and its subdomains', (domain) => { - jar.setCookie(`a=1; Domain=${domain}; Path=/`, 'https://api.example.com/') + it.each(['example.com', '.example.com', 'EXAMPLE.com'])( + 'sends a cookie with Domain=%s to the domain and its subdomains', + (domain) => { + jar.setCookie(`a=1; Domain=${domain}; Path=/`, 'https://api.example.com/') - expect(jar.getCookies()).toMatchObject([{ domain: 'example.com', hostOnly: false }]) - expect(jar.getCookies('https://example.com/')).toHaveLength(1) - expect(jar.getCookies('https://auth.example.com/')).toHaveLength(1) - expect(jar.getCookies('https://notexample.com/')).toHaveLength(0) - }) + expect(jar.getCookies()).toMatchObject([{ domain: 'example.com', hostOnly: false }]) + expect(jar.getCookies('https://example.com/')).toHaveLength(1) + expect(jar.getCookies('https://auth.example.com/')).toHaveLength(1) + expect(jar.getCookies('https://notexample.com/')).toHaveLength(0) + }, + ) it.each([ ['https://example.com/', 'other.com'], @@ -356,12 +417,15 @@ describe('cookieJar', () => { ['session=; Max-Age=0; Path=/', 'http://example.com/'], ['session=evil; Path=/docs', 'http://example.com/'], ['session=evil; Path=/', 'http://api.example.com/'], - ])('does not let an insecure origin overwrite a secure cookie with %s from %s', (setCookie, url) => { - jar.setCookie('session=good; Secure; Domain=example.com; Path=/', site) - jar.setCookie(setCookie, url) - - expect(pairs(jar)).toEqual(['session=good']) - }) + ])( + 'does not let an insecure origin overwrite a secure cookie with %s from %s', + (setCookie, url) => { + jar.setCookie('session=good; Secure; Domain=example.com; Path=/', site) + jar.setCookie(setCookie, url) + + expect(pairs(jar)).toEqual(['session=good']) + }, + ) it('lets an insecure origin set other cookies next to a secure one', () => { jar.setCookie('session=good; Secure; Path=/docs', site) diff --git a/packages/client/src/plugins/cookie-jar.ts b/packages/client/src/plugins/cookie-jar.ts index 613d29a6f5..9b9dee295c 100644 --- a/packages/client/src/plugins/cookie-jar.ts +++ b/packages/client/src/plugins/cookie-jar.ts @@ -1,9 +1,15 @@ import type { Value } from '@orpc/shared' -import type { StandardHeaders } from '@standard-server/core' -import type { FetchLinkTransportOptions } from '../adapters/fetch' -import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor, StandardLinkTransportInterceptorOptions } from '../adapters/standard' -import type { ClientContext } from '../types' import { matchesHttpPathPrefix, toArray, value } from '@orpc/shared' +import type { StandardHeaders } from '@standard-server/core' + +import type { FetchLinkTransportOptions } from '../adapters/fetch/index.ts' +import type { + StandardLinkOptions, + StandardLinkPlugin, + StandardLinkTransportInterceptor, + StandardLinkTransportInterceptorOptions, +} from '../adapters/standard/index.ts' +import type { ClientContext } from '../types.ts' export interface CookieJarLinkPluginOptions { /** @@ -48,7 +54,10 @@ export class CookieJarLinkPlugin implements StandardLin } init(options: StandardLinkOptions): StandardLinkOptions { - const transportInterceptor: StandardLinkTransportInterceptor = async ({ next, ...interceptorOptions }) => { + const transportInterceptor: StandardLinkTransportInterceptor = async ({ + next, + ...interceptorOptions + }) => { const { path, request } = interceptorOptions const origin = await value(this.origin, interceptorOptions, path) @@ -64,7 +73,10 @@ export class CookieJarLinkPlugin implements StandardLin const response = await next( cookie === undefined ? interceptorOptions - : { ...interceptorOptions, request: { ...request, headers: { ...request.headers, cookie } } }, + : { + ...interceptorOptions, + request: { ...request, headers: { ...request.headers, cookie } }, + }, ) for (const setCookie of toArray(response.headers['set-cookie'])) { @@ -76,10 +88,7 @@ export class CookieJarLinkPlugin implements StandardLin return { ...options, - transportInterceptors: [ - ...toArray(options.transportInterceptors), - transportInterceptor, - ], + transportInterceptors: [...toArray(options.transportInterceptors), transportInterceptor], } } } @@ -136,16 +145,19 @@ export class CookieJar { const path = parsed.path ?? defaultPath(target.pathname) if ( - !domainMatch(target.hostname, domain) - || (/^__(?:secure|host)-/i.test(parsed.name) && !parsed.secure) - || (/^__host-/i.test(parsed.name) && (!hostOnly || parsed.path !== '/')) + !domainMatch(target.hostname, domain) || + (/^__(?:secure|host)-/i.test(parsed.name) && !parsed.secure) || + (/^__host-/i.test(parsed.name) && (!hostOnly || parsed.path !== '/')) || // Insecure origins can neither set nor overwrite secure cookies, see "leave secure cookies alone" in RFC 6265bis - || (!secureUrl && (parsed.secure || [...this.cookies.values()].some(cookie => - cookie.secure - && cookie.name === parsed.name - && (domainMatch(domain, cookie.domain) || domainMatch(cookie.domain, domain)) - && matchesHttpPathPrefix(path as `/${string}`, cookie.path as `/${string}`), - ))) + (!secureUrl && + (parsed.secure || + [...this.cookies.values()].some( + (cookie) => + cookie.secure && + cookie.name === parsed.name && + (domainMatch(domain, cookie.domain) || domainMatch(cookie.domain, domain)) && + matchesHttpPathPrefix(path as `/${string}`, cookie.path as `/${string}`), + ))) ) { return } @@ -173,7 +185,7 @@ export class CookieJar { } } - const cookies = [...this.cookies.values()].map(cookie => ({ ...cookie })) + const cookies = [...this.cookies.values()].map((cookie) => ({ ...cookie })) if (url === undefined) { return cookies @@ -182,13 +194,19 @@ export class CookieJar { const target = new URL(url) const secureUrl = isSecureUrl(target) - return cookies - .filter(cookie => - (cookie.hostOnly ? target.hostname === cookie.domain : domainMatch(target.hostname, cookie.domain)) - && matchesHttpPathPrefix(target.pathname as `/${string}`, cookie.path as `/${string}`) - && (!cookie.secure || secureUrl), - ) - .sort((a, b) => b.path.length - a.path.length) + return ( + cookies + .filter( + (cookie) => + (cookie.hostOnly + ? target.hostname === cookie.domain + : domainMatch(target.hostname, cookie.domain)) && + matchesHttpPathPrefix(target.pathname as `/${string}`, cookie.path as `/${string}`) && + (!cookie.secure || secureUrl), + ) + // oxlint-disable-next-line unicorn/no-array-sort + .sort((a, b) => b.path.length - a.path.length) + ) } /** @@ -203,13 +221,19 @@ export class CookieJar { * Appends jar cookies to an existing `cookie` header, skipping names it already has. * Returns `undefined` when the jar adds nothing. */ -function mergeCookieHeader(header: StandardHeaders[string], cookies: StoredCookie[]): string | undefined { - const explicit = toArray(header).flatMap(value => value.split(';')).map(pair => pair.trim()).filter(Boolean) - const explicitNames = new Set(explicit.map(pair => splitCookiePair(pair)[0])) +function mergeCookieHeader( + header: StandardHeaders[string], + cookies: StoredCookie[], +): string | undefined { + const explicit = toArray(header) + .flatMap((value) => value.split(';')) + .map((pair) => pair.trim()) + .filter(Boolean) + const explicitNames = new Set(explicit.map((pair) => splitCookiePair(pair)[0])) const pairs = cookies - .filter(cookie => !explicitNames.has(cookie.name)) - .map(cookie => cookie.name ? `${cookie.name}=${cookie.value}` : cookie.value) + .filter((cookie) => !explicitNames.has(cookie.name)) + .map((cookie) => (cookie.name ? `${cookie.name}=${cookie.value}` : cookie.value)) return pairs.length ? [...explicit, ...pairs].join('; ') : undefined } @@ -228,7 +252,7 @@ function splitCookiePair(pair: string): [name: string, value: string] { */ function parseSetCookie(setCookie: string) { // Control characters other than tab make the whole cookie invalid - // eslint-disable-next-line no-control-regex + // oxlint-disable-next-line no-control-regex if (/[\x00-\x08\x0A-\x1F\x7F]/.test(setCookie)) { return undefined } @@ -249,7 +273,9 @@ function parseSetCookie(setCookie: string) { // A repeated attribute overrides the earlier ones for (const attribute of attributes) { const index = attribute.indexOf('=') - const attributeName = (index === -1 ? attribute : attribute.slice(0, index)).trim().toLowerCase() + const attributeName = (index === -1 ? attribute : attribute.slice(0, index)) + .trim() + .toLowerCase() const attributeValue = index === -1 ? '' : attribute.slice(index + 1).trim() switch (attributeName) { @@ -306,14 +332,11 @@ function parseCookieDate(value: string): number | undefined { if (time === undefined && timeMatch) { time = [Number(timeMatch[1]), Number(timeMatch[2]), Number(timeMatch[3])] - } - else if (day === undefined && digits.length >= 1 && digits.length <= 2) { + } else if (day === undefined && digits.length >= 1 && digits.length <= 2) { day = Number(digits) - } - else if (month === undefined && monthIndex !== -1) { + } else if (month === undefined && monthIndex !== -1) { month = monthIndex - } - else if (year === undefined && digits.length >= 2 && digits.length <= 4) { + } else if (year === undefined && digits.length >= 2 && digits.length <= 4) { year = Number(digits) year += year < 70 ? 2000 : year < 100 ? 1900 : 0 } @@ -341,12 +364,14 @@ function parseCookieDate(value: string): number | undefined { function isSecureUrl(url: URL): boolean { const host = url.hostname - return url.protocol === 'https:' - || url.protocol === 'wss:' - || host === 'localhost' - || host.endsWith('.localhost') - || host === '[::1]' - || /^127(?:\.\d{1,3}){3}$/.test(host) + return ( + url.protocol === 'https:' || + url.protocol === 'wss:' || + host === 'localhost' || + host.endsWith('.localhost') || + host === '[::1]' || + /^127(?:\.\d{1,3}){3}$/.test(host) + ) } /** diff --git a/packages/client/src/plugins/dedupe.test.ts b/packages/client/src/plugins/dedupe.test.ts index c3d43b8aac..564172e7e3 100644 --- a/packages/client/src/plugins/dedupe.test.ts +++ b/packages/client/src/plugins/dedupe.test.ts @@ -1,11 +1,18 @@ -import type { StandardBody, StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' import { AsyncLocalStorage } from 'node:async_hooks' import { getEventListeners } from 'node:events' + import * as SharedExperimentalV2Module from '@orpc/shared' -import { AsyncIteratorClass, asyncIteratorToStream, promiseWithResolvers, sleep } from '@orpc/shared' -import { StandardLink } from '../adapters/standard' -import { DedupeLinkPlugin } from './dedupe' +import { + AsyncIteratorClass, + asyncIteratorToStream, + promiseWithResolvers, + sleep, +} from '@orpc/shared' +import type { StandardBody, StandardLazyResponse, StandardRequest } from '@standard-server/core' + +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard/index.ts' +import { StandardLink } from '../adapters/standard/index.ts' +import { DedupeLinkPlugin } from './dedupe.ts' interface TestContext { group?: boolean @@ -14,16 +21,19 @@ interface TestContext { 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)), + 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 } @@ -35,13 +45,16 @@ 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)), + send: vi.fn( + async () => + ({ + status: 200, + headers: { + 'x-custom': '1', + }, + resolveBody, + }) satisfies StandardLazyResponse, + ), } } @@ -62,12 +75,16 @@ describe('dedupeLinkPlugin', () => { const groupCondition = vi.fn(() => true) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ - condition: groupCondition, - context: { group: true }, - }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [ + { + condition: groupCondition, + context: { group: true }, + }, + ], + }), + ], }) const [output1, output2] = await Promise.all([ @@ -86,7 +103,7 @@ describe('dedupeLinkPlugin', () => { const [request, path, callOptions] = vi.mocked(transport.send).mock.calls[0]! expect(request).toEqual({ - ...await vi.mocked(codec.encodeInput).mock.results[0]!.value, + ...(await vi.mocked(codec.encodeInput).mock.results[0]!.value), signal: allAbortSignalSpy.mock.results[0]!.value, }) expect(path).toEqual(['GET', 'planet']) @@ -100,16 +117,22 @@ describe('dedupeLinkPlugin', () => { 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' }, - })) + 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('dedupes identical QUERY requests by default', async () => { @@ -117,9 +140,11 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) const [output1, output2] = await Promise.all([ @@ -136,9 +161,11 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) const [output1, output2] = await Promise.all([ @@ -155,9 +182,11 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) const [output1, output2] = await Promise.all([ @@ -172,21 +201,22 @@ describe('dedupeLinkPlugin', () => { 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 }[], - ]) => ({ + const context = vi.fn((items: [{ context: TestContext }, ...{ context: TestContext }[]]) => ({ group: true, - tag: items.map(item => item.context.tag).join(','), + tag: items.map((item) => item.context.tag).join(','), })) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ - condition: () => true, - context, - }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [ + { + condition: () => true, + context, + }, + ], + }), + ], }) await Promise.all([ @@ -217,12 +247,16 @@ describe('dedupeLinkPlugin', () => { })) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ - condition: () => true, - context, - }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [ + { + condition: () => true, + context, + }, + ], + }), + ], }) await link.call(['GET', 'planet'], { value: 1 }, { context: { tag: 'single' }, signal }) @@ -251,9 +285,11 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport(resolveBody) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) const [output1, output2] = await Promise.all([ @@ -261,26 +297,37 @@ describe('dedupeLinkPlugin', () => { 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']) + 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 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 } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) const [output1, output2] = await Promise.all([ @@ -302,13 +349,14 @@ describe('dedupeLinkPlugin', () => { it.each([ [ 'readable-stream', - () => new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([1, 2])) - controller.enqueue(new Uint8Array([3, 4])) - controller.close() - }, - }), + () => + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])) + controller.enqueue(new Uint8Array([3, 4])) + controller.close() + }, + }), (output: unknown) => readAllStream(output as ReadableStream), [new Uint8Array([1, 2]), new Uint8Array([3, 4])], ], @@ -321,53 +369,60 @@ describe('dedupeLinkPlugin', () => { (output: unknown) => readAllAsync(output as AsyncIterable), ['first', 'second'], ], - ])('reuses the resolved %s body for repeated and concurrent reads of the same replicated response', async (_name, createBody, readAll, expected) => { - const codec: StandardLinkCodec = { - ...makeCodec(), - decodeResponse: vi.fn(async (response) => { - const [firstBody, concurrentBody] = await Promise.all([ - response.resolveBody(), - response.resolveBody(), - ]) - const laterBody = await response.resolveBody() - - expect(concurrentBody).toBe(firstBody) - expect(laterBody).toBe(firstBody) + ])( + 'reuses the resolved %s body for repeated and concurrent reads of the same replicated response', + async (_name, createBody, readAll, expected) => { + const codec: StandardLinkCodec = { + ...makeCodec(), + decodeResponse: vi.fn(async (response) => { + const [firstBody, concurrentBody] = await Promise.all([ + response.resolveBody(), + response.resolveBody(), + ]) + const laterBody = await response.resolveBody() + + expect(concurrentBody).toBe(firstBody) + expect(laterBody).toBe(firstBody) + + return { + kind: 'output' as const, + output: firstBody, + } + }), + } + const resolveBody = vi.fn(async () => createBody()) + const transport = makeTransport(resolveBody) - return { - kind: 'output' as const, - output: firstBody, - } - }), - } - const resolveBody = vi.fn(async () => createBody()) - const transport = makeTransport(resolveBody) + const link = new StandardLink(codec, transport, { + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], + }) - 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: {} }), + ]) - const [output1, output2] = await Promise.all([ - link.call(['GET', 'planet'], { value: 1 }, { context: {} }), - link.call(['GET', 'planet'], { value: 1 }, { context: {} }), - ]) - - await expect(readAll(output1)).resolves.toEqual(expected) - await expect(readAll(output2)).resolves.toEqual(expected) - expect(resolveBody).toHaveBeenCalledTimes(1) - }) + await expect(readAll(output1)).resolves.toEqual(expected) + await expect(readAll(output2)).resolves.toEqual(expected) + 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, - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + filter: () => true, + }), + ], }) const [output1, output2] = await Promise.all([ @@ -383,22 +438,32 @@ describe('dedupeLinkPlugin', () => { ['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' }())], + [ + '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, - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + filter: () => true, + }), + ], }) await Promise.all([ @@ -417,9 +482,11 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) await Promise.all([ @@ -437,9 +504,11 @@ describe('dedupeLinkPlugin', () => { vi.mocked(transport.send).mockRejectedValue(new Error('FAIL')) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) const promise1 = link.call(['GET', 'planet'], { value: 1 }, { context: {} }) @@ -463,12 +532,18 @@ describe('dedupeLinkPlugin', () => { }) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) - const promise1 = link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }) + const promise1 = link.call( + ['GET', 'planet'], + { value: 1 }, + { context: {}, signal: controller.signal }, + ) const promise2 = link.call(['GET', 'planet'], { value: 1 }, { context: {} }) await vi.waitFor(() => expect(transport.send).toHaveBeenCalledTimes(1)) @@ -477,7 +552,7 @@ describe('dedupeLinkPlugin', () => { await expect(promise1).rejects.toBe(controller.signal.reason) release.resolve() - const iterator = await promise2 as AsyncIteratorObject + const iterator = (await promise2) as AsyncIteratorObject const next = iterator.next() tick() await expect(next).resolves.toEqual({ done: false, value: 'tick' }) @@ -497,15 +572,29 @@ describe('dedupeLinkPlugin', () => { const context = vi.fn(() => ({ group: true })) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - wait: 100, - groups: [{ condition: () => true, context }], - })], + plugins: [ + new DedupeLinkPlugin({ + wait: 100, + groups: [{ condition: () => true, context }], + }), + ], }) - const promise1 = link.call(['GET', 'planet'], { value: 1 }, { context: { tag: 'first' }, signal: controller1.signal }) - const promise2 = link.call(['GET', 'planet'], { value: 1 }, { context: { tag: 'second' }, signal: signal2 }) - const promise3 = link.call(['GET', 'planet'], { value: 2 }, { context: {}, signal: controller3.signal }) + const promise1 = link.call( + ['GET', 'planet'], + { value: 1 }, + { context: { tag: 'first' }, signal: controller1.signal }, + ) + const promise2 = link.call( + ['GET', 'planet'], + { value: 1 }, + { context: { tag: 'second' }, signal: signal2 }, + ) + const promise3 = link.call( + ['GET', 'planet'], + { value: 2 }, + { context: {}, signal: controller3.signal }, + ) await vi.advanceTimersByTimeAsync(10) controller1.abort() @@ -534,12 +623,18 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport(() => body.promise) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) - const promise1 = link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }) + const promise1 = link.call( + ['GET', 'planet'], + { value: 1 }, + { context: {}, signal: controller.signal }, + ) const promise2 = link.call(['GET', 'planet'], { value: 1 }, { context: {} }) await vi.waitFor(() => expect(codec.decodeResponse).toHaveBeenCalledTimes(2)) @@ -562,13 +657,17 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport(async () => body) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) const [reader1, reader2] = await Promise.all([ - link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }).then(openReader), + link + .call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }) + .then(openReader), link.call(['GET', 'planet'], { value: 1 }, { context: {} }).then(openReader), ]) @@ -598,14 +697,18 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport(async () => body) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) // The first caller never reads, so its replica keeps what it receives const [, reader2] = await Promise.all([ - link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }).then(openReader), + link + .call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }) + .then(openReader), link.call(['GET', 'planet'], { value: 1 }, { context: {} }).then(openReader), ]) @@ -626,12 +729,18 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport(() => resolvedBody.promise) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) - const promise1 = link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }) + const promise1 = link.call( + ['GET', 'planet'], + { value: 1 }, + { context: {}, signal: controller.signal }, + ) const promise2 = link.call(['GET', 'planet'], { value: 1 }, { context: {} }) await vi.waitFor(() => expect(codec.decodeResponse).toHaveBeenCalledTimes(2)) @@ -648,41 +757,50 @@ describe('dedupeLinkPlugin', () => { it.each([ ['ends', true], ['fails', new Error('FAIL')], - ] as const)('removes the abort listener once the replica is cancelled or the body %s', async (_name, end) => { - const controller1 = new AbortController() - const controller2 = new AbortController() - const codec = makeCodec() - const { body, tick } = createTickingBody() - const transport = makeTransport(async () => body) - - const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], - }) - - const [reader1, reader2] = await Promise.all([ - link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller1.signal }).then(openReader), - link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller2.signal }).then(openReader), - ]) + ] as const)( + 'removes the abort listener once the replica is cancelled or the body %s', + async (_name, end) => { + const controller1 = new AbortController() + const controller2 = new AbortController() + const codec = makeCodec() + const { body, tick } = createTickingBody() + const transport = makeTransport(async () => body) + + const link = new StandardLink(codec, transport, { + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], + }) + + const [reader1, reader2] = await Promise.all([ + link + .call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller1.signal }) + .then(openReader), + link + .call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller2.signal }) + .then(openReader), + ]) - const listenerCount1 = getEventListeners(controller1.signal, 'abort').length - const listenerCount2 = getEventListeners(controller2.signal, 'abort').length + const listenerCount1 = getEventListeners(controller1.signal, 'abort').length + const listenerCount2 = getEventListeners(controller2.signal, 'abort').length - // A cancelled stream branch settles only once the other branch cancels or the source ends - const cancel1 = reader1.cancel() - await sleep(0) + // A cancelled stream branch settles only once the other branch cancels or the source ends + const cancel1 = reader1.cancel() + await sleep(0) - expect(getEventListeners(controller1.signal, 'abort')).toHaveLength(listenerCount1 - 1) - expect(getEventListeners(controller2.signal, 'abort')).toHaveLength(listenerCount2) + expect(getEventListeners(controller1.signal, 'abort')).toHaveLength(listenerCount1 - 1) + expect(getEventListeners(controller2.signal, 'abort')).toHaveLength(listenerCount2) - const read2 = reader2.read().catch(error => error) - tick(end) - await expect(read2).resolves.toEqual(end === true ? { done: true, value: undefined } : end) - await cancel1 + const read2 = reader2.read().catch((error) => error) + tick(end) + await expect(read2).resolves.toEqual(end === true ? { done: true, value: undefined } : end) + await cancel1 - expect(getEventListeners(controller2.signal, 'abort')).toHaveLength(listenerCount2 - 1) - }) + expect(getEventListeners(controller2.signal, 'abort')).toHaveLength(listenerCount2 - 1) + }, + ) it('swallows a failure to cancel the shared source when the last reader aborts', async () => { const controller = new AbortController() @@ -692,18 +810,22 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport(async () => body) const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + }), + ], }) const [reader1, reader2] = await Promise.all([ - link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }).then(openReader), + link + .call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }) + .then(openReader), link.call(['GET', 'planet'], { value: 1 }, { context: {} }).then(openReader), ]) // A cancelled stream branch settles with the source's cancel result once the other branch cancels - const cancel2 = reader2.cancel().catch(error => error) + const cancel2 = reader2.cancel().catch((error) => error) const read1 = reader1.read() await sleep(0) controller.abort() @@ -719,9 +841,11 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => false, context: { group: true } }], - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => false, context: { group: true } }], + }), + ], }) const [output1, output2] = await Promise.all([ @@ -740,10 +864,12 @@ describe('dedupeLinkPlugin', () => { const transport = makeTransport() const link = new StandardLink(codec, transport, { - plugins: [new DedupeLinkPlugin({ - wait: 100, - groups: [{ condition: () => true, context: {} }], - })], + plugins: [ + new DedupeLinkPlugin({ + wait: 100, + groups: [{ condition: () => true, context: {} }], + }), + ], }) const promise1 = link.call(['GET', 'planet'], { value: 1 }, { context: {} }) @@ -781,17 +907,19 @@ describe('dedupeLinkPlugin', () => { } } - it('sends each request in its first caller\'s async context', async () => { + it("sends each request in its first caller's async context", async () => { const transport = makeUserTransport() const link = new StandardLink(makeCodec(), transport, { plugins: [new DedupeLinkPlugin({ groups: [{ condition: () => true, context: {} }] })], }) - await expect(Promise.all([ - storage.run('alice', () => link.call(['GET', 'a'], {}, { context: {} })), - storage.run('bob', () => link.call(['GET', 'b'], {}, { context: {} })), - storage.run('carol', () => link.call(['GET', 'b'], {}, { context: {} })), - ])).resolves.toEqual(['alice', 'bob', 'bob']) + await expect( + Promise.all([ + storage.run('alice', () => link.call(['GET', 'a'], {}, { context: {} })), + storage.run('bob', () => link.call(['GET', 'b'], {}, { context: {} })), + storage.run('carol', () => link.call(['GET', 'b'], {}, { context: {} })), + ]), + ).resolves.toEqual(['alice', 'bob', 'bob']) expect(transport.send).toHaveBeenCalledTimes(2) }) @@ -802,10 +930,14 @@ describe('dedupeLinkPlugin', () => { const controller = new AbortController() const transport = makeUserTransport() const link = new StandardLink(makeCodec(), transport, { - plugins: [new DedupeLinkPlugin({ wait: 100, groups: [{ condition: () => true, context: {} }] })], + plugins: [ + new DedupeLinkPlugin({ wait: 100, groups: [{ condition: () => true, context: {} }] }), + ], }) - const alice = storage.run('alice', () => link.call(['GET', 'me'], {}, { context: {}, signal: controller.signal })) + const alice = storage.run('alice', () => + link.call(['GET', 'me'], {}, { context: {}, signal: controller.signal }), + ) const bob = storage.run('bob', () => link.call(['GET', 'me'], {}, { context: {} })) await vi.advanceTimersByTimeAsync(10) @@ -820,15 +952,21 @@ describe('dedupeLinkPlugin', () => { it('dedupes only requests with the same scope', async () => { const transport = makeUserTransport() const link = new StandardLink(makeCodec(), transport, { - plugins: [new DedupeLinkPlugin({ - groups: [{ condition: () => true, context: {} }], - scope: () => storage.getStore(), - })], + plugins: [ + new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: {} }], + scope: () => storage.getStore(), + }), + ], }) - await expect(Promise.all(['alice', 'bob', 'alice', 'bob'].map(user => - storage.run(user, () => link.call(['GET', 'me'], {}, { context: {} })), - ))).resolves.toEqual(['alice', 'bob', 'alice', 'bob']) + await expect( + Promise.all( + ['alice', 'bob', 'alice', 'bob'].map((user) => + storage.run(user, () => link.call(['GET', 'me'], {}, { context: {} })), + ), + ), + ).resolves.toEqual(['alice', 'bob', 'alice', 'bob']) expect(transport.send).toHaveBeenCalledTimes(2) }) @@ -848,24 +986,27 @@ function createTickingIterator(cancelError?: Error): TickingBody() - const body = new AsyncIteratorClass(async () => { - const end = await ticks.promise - ticks = promiseWithResolvers() + const body = new AsyncIteratorClass( + async () => { + const end = await ticks.promise + ticks = promiseWithResolvers() - if (end instanceof Error) { - throw end - } + if (end instanceof Error) { + throw end + } - return end ? { done: true, value: undefined } : { done: false, value: 'tick' } - }, async ({ kind }) => { - cancelled ||= kind === 'cancelled' + return end ? { done: true, value: undefined } : { done: false, value: 'tick' } + }, + async ({ kind }) => { + cancelled ||= kind === 'cancelled' - if (cancelled && cancelError) { - throw cancelError - } - }) + if (cancelled && cancelError) { + throw cancelError + } + }, + ) - return { body, tick: end => ticks.resolve(end), isCancelled: () => cancelled } + return { body, tick: (end) => ticks.resolve(end), isCancelled: () => cancelled } } function createTickingStream(cancelError?: Error): TickingBody> { @@ -873,14 +1014,17 @@ function createTickingStream(cancelError?: Error): TickingBody Promise, cancel: () => Promise } { +function openReader(output: unknown): { + read: () => Promise + cancel: () => Promise +} { if (output instanceof ReadableStream) { const reader = output.getReader() return { read: () => reader.read(), cancel: () => reader.cancel() } } const iterator = output as AsyncIteratorObject - return { read: () => iterator.next(), cancel: async () => void await iterator.return?.() } + return { read: () => iterator.next(), cancel: async () => void (await iterator.return?.()) } } async function readAllAsync(iterator: AsyncIterable): Promise { @@ -907,8 +1051,7 @@ async function readAllStream(stream: ReadableStream): Promise { values.push(result.value) } - } - finally { + } finally { reader.releaseLock() } } diff --git a/packages/client/src/plugins/dedupe.ts b/packages/client/src/plugins/dedupe.ts index 1c981b3602..77a73c2044 100644 --- a/packages/client/src/plugins/dedupe.ts +++ b/packages/client/src/plugins/dedupe.ts @@ -1,19 +1,43 @@ import type { AsyncIteratorClass, InterceptorOptions, Value } from '@orpc/shared' +import { + allAbortSignal, + captureAsyncContext, + defer, + isAsyncIteratorObject, + replicateAsyncIterator, + replicateReadableStream, + runWithSignal, + stringifyJSON, + throwIfAborted, + toArray, + value, + wrapAsyncIterator, +} from '@orpc/shared' import type { StandardBody, StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor, StandardLinkTransportInterceptorOptions } from '../adapters/standard' -import type { ClientContext } from '../types' -import { allAbortSignal, captureAsyncContext, defer, isAsyncIteratorObject, replicateAsyncIterator, replicateReadableStream, runWithSignal, stringifyJSON, throwIfAborted, toArray, value, wrapAsyncIterator } from '@orpc/shared' + +import type { + StandardLinkOptions, + StandardLinkPlugin, + StandardLinkTransportInterceptor, + StandardLinkTransportInterceptorOptions, +} from '../adapters/standard/index.ts' +import type { ClientContext } from '../types.ts' export interface DedupeLinkPluginGroup { condition: Value]> /** * The context used for the rest of the request lifecycle. */ - context: Value, - StandardLinkTransportInterceptorOptions, - ...StandardLinkTransportInterceptorOptions[], - ]]> + context: Value< + T, + [ + items: [ + StandardLinkTransportInterceptorOptions, + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ], + ] + > } export interface DedupeLinkPluginOptions { @@ -63,22 +87,29 @@ export class DedupeLinkPlugin implements StandardLinkPl private readonly scope: Exclude['scope'], undefined> private readonly wait: Exclude['wait'], undefined> - private readonly queue: Map, Map[]>>> = new Map() + private readonly queue: Map< + unknown, + Map, Map[]>> + > = new Map() constructor(options: NoInfer>) { this.groups = options.groups - this.filter = options.filter ?? (({ request }) => request.method === 'GET' || request.method === 'QUERY') + this.filter = + options.filter ?? (({ request }) => request.method === 'GET' || request.method === 'QUERY') this.scope = options.scope ?? (() => undefined) this.wait = options.wait ?? 0 } init(options: StandardLinkOptions): StandardLinkOptions { const transportInterceptor: StandardLinkTransportInterceptor = (interceptorOptions) => { - if (!canDedupeRequest(interceptorOptions.request) || !value(this.filter, interceptorOptions)) { + if ( + !canDedupeRequest(interceptorOptions.request) || + !value(this.filter, interceptorOptions) + ) { return interceptorOptions.next() } - const group = this.groups.find(group => value(group.condition, interceptorOptions)) + const group = this.groups.find((group) => value(group.condition, interceptorOptions)) if (!group) { return interceptorOptions.next() @@ -86,14 +117,23 @@ export class DedupeLinkPlugin implements StandardLinkPl const scope = this.scope(interceptorOptions) - return runWithSignal(interceptorOptions.request.signal, () => new Promise((resolve, reject) => { - // Schedule only for the first queued request, so later ones cannot extend or split the wait. - if (!this.queue.size) { - defer(() => this.processPendingRequests(), this.wait) - } - - this.enqueue(scope, group, { options: interceptorOptions, resolve, reject, runInOwnContext: captureAsyncContext() }) - })) + return runWithSignal( + interceptorOptions.request.signal, + () => + new Promise((resolve, reject) => { + // Schedule only for the first queued request, so later ones cannot extend or split the wait. + if (!this.queue.size) { + defer(() => this.processPendingRequests(), this.wait) + } + + this.enqueue(scope, group, { + options: interceptorOptions, + resolve, + reject, + runInOwnContext: captureAsyncContext(), + }) + }), + ) } return { @@ -137,7 +177,7 @@ export class DedupeLinkPlugin implements StandardLinkPl for (const groups of pending) { for (const [group, items] of groups) { for (const callers of items.values()) { - const activeCallers = callers.filter(caller => !caller.options.request.signal?.aborted) + const activeCallers = callers.filter((caller) => !caller.options.request.signal?.aborted) const [first] = activeCallers if (first) { @@ -156,7 +196,7 @@ export class DedupeLinkPlugin implements StandardLinkPl activeCallers: DedupeCaller[], ): Promise { const first = activeCallers[0]! - const matchedOptions = activeCallers.map(caller => caller.options) + const matchedOptions = activeCallers.map((caller) => caller.options) try { if (!shouldDedupe(matchedOptions)) { @@ -166,7 +206,7 @@ export class DedupeLinkPlugin implements StandardLinkPl } const context = value(group.context, matchedOptions) as T - const signals = matchedOptions.map(options => options.request.signal) + const signals = matchedOptions.map((options) => options.request.signal) const request: StandardRequest = { ...first.options.request, @@ -183,8 +223,7 @@ export class DedupeLinkPlugin implements StandardLinkPl const replicatedResponses = replicateLazyResponse(response, signals) activeCallers.forEach((caller, index) => caller.resolve(replicatedResponses[index]!)) - } - catch (error) { + } catch (error) { for (const caller of activeCallers) { caller.reject(error) } @@ -193,7 +232,10 @@ export class DedupeLinkPlugin implements StandardLinkPl } type DedupeCaller = { - options: InterceptorOptions, Promise> + options: InterceptorOptions< + StandardLinkTransportInterceptorOptions, + Promise + > resolve: (response: StandardLazyResponse) => void reject: (error: unknown) => void runInOwnContext: ReturnType @@ -201,12 +243,12 @@ type DedupeCaller = { 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 + request.body instanceof Blob || + request.body instanceof FormData || + request.body instanceof URLSearchParams || + request.body instanceof ReadableStream || + isAsyncIteratorObject(request.body) || + request.signal?.aborted ) } @@ -224,28 +266,34 @@ function createRequestKey(path: string[], request: StandardRequest): string { * Replicates the response once per signal. Like an unshared response, each replica's body * fails and stops streaming once its own signal aborts, without affecting the other replicas. */ -function replicateLazyResponse(response: StandardLazyResponse, signals: (AbortSignal | undefined)[]): StandardLazyResponse[] { +function replicateLazyResponse( + response: StandardLazyResponse, + signals: (AbortSignal | undefined)[], +): StandardLazyResponse[] { let bodiesPromise: Promise | undefined return signals.map((signal, i) => ({ ...response, - resolveBody: hint => runWithSignal(signal, async () => { - bodiesPromise ??= response.resolveBody(hint).then((body) => { - if (isAsyncIteratorObject(body)) { - return replicateAsyncIterator(body, signals.length) - .map((replica, index) => closeAsyncIteratorOnAbort(replica, signals[index])) - } + resolveBody: (hint) => + runWithSignal(signal, async () => { + bodiesPromise ??= response.resolveBody(hint).then((body) => { + if (isAsyncIteratorObject(body)) { + return replicateAsyncIterator(body, signals.length).map((replica, index) => + closeAsyncIteratorOnAbort(replica, signals[index]), + ) + } - if (body instanceof ReadableStream) { - return replicateReadableStream(body, signals.length) - .map((replica, index) => closeReadableStreamOnAbort(replica, signals[index])) - } + if (body instanceof ReadableStream) { + return replicateReadableStream(body, signals.length).map((replica, index) => + closeReadableStreamOnAbort(replica, signals[index]), + ) + } - return signals.map(() => body) - }) + return signals.map(() => body) + }) - return (await bodiesPromise)[i] - }), + return (await bodiesPromise)[i] + }), })) } @@ -261,8 +309,7 @@ function closeAsyncIteratorOnAbort( if (signal.aborted) { void abort() - } - else { + } else { signal.addEventListener('abort', abort, { once: true }) } @@ -280,7 +327,10 @@ function closeAsyncIteratorOnAbort( * Cancels the replica on abort even when nothing is reading it, which an aborted * `pipeThrough` would not do until the in-flight chunk is read. */ -function closeReadableStreamOnAbort(replica: ReadableStream, signal: AbortSignal | undefined): ReadableStream { +function closeReadableStreamOnAbort( + replica: ReadableStream, + signal: AbortSignal | undefined, +): ReadableStream { if (!signal) { return replica } @@ -290,36 +340,36 @@ function closeReadableStreamOnAbort(replica: ReadableStream, signal: Abort if (signal.aborted) { void abort() - } - else { + } else { signal.addEventListener('abort', abort, { once: true }) } - return new ReadableStream({ - async pull(controller) { - try { - const result = await reader.read() - // Cancelling the replica reports its reads as done rather than failed. - throwIfAborted(signal) - - if (result.done) { + return new ReadableStream( + { + async pull(controller) { + try { + const result = await reader.read() + // Cancelling the replica reports its reads as done rather than failed. + throwIfAborted(signal) + + if (result.done) { + signal.removeEventListener('abort', abort) + controller.close() + } else { + controller.enqueue(result.value) + } + } catch (error) { signal.removeEventListener('abort', abort) - controller.close() + throw error } - else { - controller.enqueue(result.value) - } - } - catch (error) { + }, + async cancel(reason) { signal.removeEventListener('abort', abort) - throw error - } + await reader.cancel(reason) + }, }, - async cancel(reason) { - signal.removeEventListener('abort', abort) - await reader.cancel(reason) - }, - }, { highWaterMark: 0 }) + { highWaterMark: 0 }, + ) } function shouldDedupe( diff --git a/packages/client/src/plugins/index.test.ts b/packages/client/src/plugins/index.test.ts index 95b6e36d0c..b5d8329fd9 100644 --- a/packages/client/src/plugins/index.test.ts +++ b/packages/client/src/plugins/index.test.ts @@ -1,5 +1,5 @@ it('exports plugins', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ BatchLinkPlugin: expect.any(Function), BATCH_HEADER: 'orpc-batch', DEFAULT_BATCH_MAX_SIZE: 10, diff --git a/packages/client/src/plugins/index.ts b/packages/client/src/plugins/index.ts index 7f820b30a0..0a73343443 100644 --- a/packages/client/src/plugins/index.ts +++ b/packages/client/src/plugins/index.ts @@ -1,32 +1,32 @@ -export * from './batch' -export * from './cookie-jar' -export * from './dedupe' +export * from './batch.ts' +export * from './cookie-jar.ts' +export * from './dedupe.ts' export { /** * @deprecated Use `DedupeLinkPlugin` instead. */ DedupeLinkPlugin as DedupeRequestsPlugin, -} from './dedupe' -export * from './request-compression' -export * from './response-compression' -export * from './retry' +} from './dedupe.ts' +export * from './request-compression.ts' +export * from './response-compression.ts' +export * from './retry.ts' export { /** * @deprecated Use `RetryLinkPlugin` instead. */ RetryLinkPlugin as ClientRetryPlugin, -} from './retry' +} from './retry.ts' export type { /** * @deprecated Use `RetryLinkPluginContext` instead. */ RetryLinkPluginContext as ClientRetryPluginContext, -} from './retry' -export * from './retry-after' +} from './retry.ts' +export * from './retry-after.ts' export { /** * @deprecated Use `RetryAfterLinkPlugin` instead. */ RetryAfterLinkPlugin as RetryAfterPlugin, -} from './retry-after' -export * from './timeout' +} from './retry-after.ts' +export * from './timeout.ts' diff --git a/packages/client/src/plugins/request-compression.test.ts b/packages/client/src/plugins/request-compression.test.ts index f667780fa1..9a550c8b23 100644 --- a/packages/client/src/plugins/request-compression.test.ts +++ b/packages/client/src/plugins/request-compression.test.ts @@ -1,12 +1,16 @@ -import type { StandardLinkTransportInterceptor } from '../adapters/standard' import { RPCLink } from '@orpc/client/fetch' -import { RequestCompressionLinkPlugin } from './request-compression' + +import type { StandardLinkTransportInterceptor } from '../adapters/standard/index.ts' +import { RequestCompressionLinkPlugin } from './request-compression.ts' beforeEach(() => { vi.clearAllMocks() }) -async function decompressStream(stream: ReadableStream, encoding: 'gzip' | 'deflate' | 'deflate-raw'): Promise { +async function decompressStream( + stream: ReadableStream, + encoding: 'gzip' | 'deflate' | 'deflate-raw', +): Promise { const decompressed = stream.pipeThrough(new DecompressionStream(encoding)) const reader = decompressed.getReader() const chunks: Uint8Array[] = [] @@ -31,21 +35,24 @@ async function decompressStream(stream: ReadableStream, encoding: 'gzip' | 'defl return new TextDecoder().decode(result) } -function createLink(options: { - pluginOptions?: ConstructorParameters[0] - transportInterceptors?: StandardLinkTransportInterceptor[] -} = {}) { +function createLink( + options: { + pluginOptions?: ConstructorParameters[0] + transportInterceptors?: StandardLinkTransportInterceptor[] + } = {}, +) { const fetch = vi.fn( - async (_url: string, init: { body: any, headers: Headers }) => new Response(JSON.stringify({ json: 'OK' }), { headers: { 'content-type': 'application/json' } }), + async (_url: string, init: { body: any; headers: Headers }) => + new Response(JSON.stringify({ json: 'OK' }), { + headers: { 'content-type': 'application/json' }, + }), ) const link = new RPCLink({ url: '/rpc', origin: 'http://localhost:3000', method: () => 'POST', - plugins: [ - new RequestCompressionLinkPlugin(options.pluginOptions), - ], + plugins: [new RequestCompressionLinkPlugin(options.pluginOptions)], transportInterceptors: options.transportInterceptors, fetch, }) @@ -103,9 +110,9 @@ describe('requestCompressionLinkPlugin', () => { expect(init.headers?.get('content-disposition')).toBeNull() expect(init.headers?.get('content-encoding')).toBe('gzip') - await expect( - decompressStream(init.body as ReadableStream, 'gzip'), - ).resolves.toEqual(JSON.stringify({ json: largeObject })) + await expect(decompressStream(init.body as ReadableStream, 'gzip')).resolves.toEqual( + JSON.stringify({ json: largeObject }), + ) }) }) @@ -141,9 +148,9 @@ describe('requestCompressionLinkPlugin', () => { expect(init.headers?.get('content-disposition')).toContain('blob') expect(init.headers?.get('content-encoding')).toBe('gzip') - await expect( - decompressStream(init.body as ReadableStream, 'gzip'), - ).resolves.toEqual(await largeBlob.text()) + await expect(decompressStream(init.body as ReadableStream, 'gzip')).resolves.toEqual( + await largeBlob.text(), + ) }) it('should not compress unsupported content-type blob', async () => { @@ -163,21 +170,26 @@ describe('requestCompressionLinkPlugin', () => { }) it('should respect existing content-disposition header for compressed blob body', async () => { - const transportInterceptor = vi.fn>(async ({ next, ...interceptorOptions }) => { - const request = interceptorOptions.request - - return next({ - ...interceptorOptions, - request: { - ...request, - headers: { - ...request.headers, - 'content-disposition': '__CUSTOM__', + const transportInterceptor = vi.fn>( + async ({ next, ...interceptorOptions }) => { + const request = interceptorOptions.request + + return next({ + ...interceptorOptions, + request: { + ...request, + headers: { + ...request.headers, + 'content-disposition': '__CUSTOM__', + }, }, - }, - }) + }) + }, + ) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100 }, + transportInterceptors: [transportInterceptor], }) - const { link, fetch } = createLink({ pluginOptions: { threshold: 100 }, transportInterceptors: [transportInterceptor] }) const largeBlob = new Blob(['large content'.repeat(100)], { type: 'text/plain' }) await expect(link.call(['test'], largeBlob, { context: {} })).resolves.toEqual('OK') @@ -247,22 +259,27 @@ describe('requestCompressionLinkPlugin', () => { describe('readable stream body', () => { it('should compress ReadableStream body above threshold with compressible content-type', async () => { - const transportInterceptor = vi.fn>(async ({ next, ...interceptorOptions }) => { - const request = interceptorOptions.request - - return next({ - ...interceptorOptions, - request: { - ...request, - headers: { - ...request.headers, - 'content-type': 'text/plain', - 'content-length': '200', + const transportInterceptor = vi.fn>( + async ({ next, ...interceptorOptions }) => { + const request = interceptorOptions.request + + return next({ + ...interceptorOptions, + request: { + ...request, + headers: { + ...request.headers, + 'content-type': 'text/plain', + 'content-length': '200', + }, }, - }, - }) + }) + }, + ) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100 }, + transportInterceptors: [transportInterceptor], }) - const { link, fetch } = createLink({ pluginOptions: { threshold: 100 }, transportInterceptors: [transportInterceptor] }) const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('large content'.repeat(100))) @@ -280,28 +297,33 @@ describe('requestCompressionLinkPlugin', () => { expect(init.headers?.get('content-length')).toBeNull() expect(init.headers?.get('content-encoding')).toBe('gzip') - await expect( - decompressStream(init.body as ReadableStream, 'gzip'), - ).resolves.toEqual('large content'.repeat(100)) + await expect(decompressStream(init.body as ReadableStream, 'gzip')).resolves.toEqual( + 'large content'.repeat(100), + ) }) it('should not compress ReadableStream body below threshold', async () => { - const transportInterceptor = vi.fn>(async ({ next, ...interceptorOptions }) => { - const request = interceptorOptions.request - - return next({ - ...interceptorOptions, - request: { - ...request, - headers: { - ...request.headers, - 'content-type': 'text/plain', - 'content-length': '50', + const transportInterceptor = vi.fn>( + async ({ next, ...interceptorOptions }) => { + const request = interceptorOptions.request + + return next({ + ...interceptorOptions, + request: { + ...request, + headers: { + ...request.headers, + 'content-type': 'text/plain', + 'content-length': '50', + }, }, - }, - }) + }) + }, + ) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100 }, + transportInterceptors: [transportInterceptor], }) - const { link, fetch } = createLink({ pluginOptions: { threshold: 100 }, transportInterceptors: [transportInterceptor] }) const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('small')) @@ -318,22 +340,27 @@ describe('requestCompressionLinkPlugin', () => { }) it('should not compress ReadableStream body with non-compressible content-type', async () => { - const transportInterceptor = vi.fn>(async ({ next, ...interceptorOptions }) => { - const request = interceptorOptions.request - - return next({ - ...interceptorOptions, - request: { - ...request, - headers: { - ...request.headers, - 'content-type': 'image/png', - 'content-length': '200', + const transportInterceptor = vi.fn>( + async ({ next, ...interceptorOptions }) => { + const request = interceptorOptions.request + + return next({ + ...interceptorOptions, + request: { + ...request, + headers: { + ...request.headers, + 'content-type': 'image/png', + 'content-length': '200', + }, }, - }, - }) + }) + }, + ) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100 }, + transportInterceptors: [transportInterceptor], }) - const { link, fetch } = createLink({ pluginOptions: { threshold: 100 }, transportInterceptors: [transportInterceptor] }) const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('large content'.repeat(100))) @@ -350,21 +377,26 @@ describe('requestCompressionLinkPlugin', () => { }) it('should compress ReadableStream body when content-length is unknown with compressible content-type', async () => { - const transportInterceptor = vi.fn>(async ({ next, ...interceptorOptions }) => { - const request = interceptorOptions.request - - return next({ - ...interceptorOptions, - request: { - ...request, - headers: { - ...request.headers, - 'content-type': 'text/plain', + const transportInterceptor = vi.fn>( + async ({ next, ...interceptorOptions }) => { + const request = interceptorOptions.request + + return next({ + ...interceptorOptions, + request: { + ...request, + headers: { + ...request.headers, + 'content-type': 'text/plain', + }, }, - }, - }) + }) + }, + ) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100 }, + transportInterceptors: [transportInterceptor], }) - const { link, fetch } = createLink({ pluginOptions: { threshold: 100 }, transportInterceptors: [transportInterceptor] }) const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('large content'.repeat(100))) @@ -382,9 +414,9 @@ describe('requestCompressionLinkPlugin', () => { expect(init.headers?.get('content-length')).toBeNull() expect(init.headers?.get('content-encoding')).toBe('gzip') - await expect( - decompressStream(init.body as ReadableStream, 'gzip'), - ).resolves.toEqual('large content'.repeat(100)) + await expect(decompressStream(init.body as ReadableStream, 'gzip')).resolves.toEqual( + 'large content'.repeat(100), + ) }) }) @@ -508,19 +540,24 @@ describe('requestCompressionLinkPlugin', () => { describe('url search params body', () => { it('should compress URLSearchParams body above threshold', async () => { - const transportInterceptor = vi.fn>(async ({ next, ...interceptorOptions }) => { - const request = interceptorOptions.request - const params = new URLSearchParams({ value: 'a'.repeat(100) }) - - return next({ - ...interceptorOptions, - request: { - ...request, - body: params, - }, - }) + const transportInterceptor = vi.fn>( + async ({ next, ...interceptorOptions }) => { + const request = interceptorOptions.request + const params = new URLSearchParams({ value: 'a'.repeat(100) }) + + return next({ + ...interceptorOptions, + request: { + ...request, + body: params, + }, + }) + }, + ) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100 }, + transportInterceptors: [transportInterceptor], }) - const { link, fetch } = createLink({ pluginOptions: { threshold: 100 }, transportInterceptors: [transportInterceptor] }) await expect(link.call(['test'], { foo: 'bar' }, { context: {} })).resolves.toEqual('OK') @@ -532,25 +569,30 @@ describe('requestCompressionLinkPlugin', () => { expect(init.headers?.get('content-length')).toBeNull() expect(init.headers?.get('content-encoding')).toBe('gzip') - await expect( - decompressStream(init.body as ReadableStream, 'gzip'), - ).resolves.toEqual(`value=${encodeURIComponent('a'.repeat(100))}`) + await expect(decompressStream(init.body as ReadableStream, 'gzip')).resolves.toEqual( + `value=${encodeURIComponent('a'.repeat(100))}`, + ) }) it('should not compress URLSearchParams body below threshold', async () => { - const transportInterceptor = vi.fn>(async ({ next, ...interceptorOptions }) => { - const request = interceptorOptions.request - const params = new URLSearchParams({ value: 'hi' }) - - return next({ - ...interceptorOptions, - request: { - ...request, - body: params, - }, - }) + const transportInterceptor = vi.fn>( + async ({ next, ...interceptorOptions }) => { + const request = interceptorOptions.request + const params = new URLSearchParams({ value: 'hi' }) + + return next({ + ...interceptorOptions, + request: { + ...request, + body: params, + }, + }) + }, + ) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100 }, + transportInterceptors: [transportInterceptor], }) - const { link, fetch } = createLink({ pluginOptions: { threshold: 100 }, transportInterceptors: [transportInterceptor] }) await expect(link.call(['test'], { foo: 'bar' }, { context: {} })).resolves.toEqual('OK') @@ -593,7 +635,10 @@ describe('requestCompressionLinkPlugin', () => { name: 'URLSearchParams with a form content-type', body: () => new URLSearchParams({ text: largeText }), headers: { 'content-type': 'application/x-www-form-urlencoded; charset=utf-8' }, - expected: { 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', 'standard-server': null }, + expected: { + 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', + 'standard-server': null, + }, }, { name: 'ReadableStream with a body hint', @@ -608,23 +653,32 @@ describe('requestCompressionLinkPlugin', () => { expected: { 'content-type': 'text/csv', 'standard-server': 'octet-stream' }, }, ])('keeps them for $name body', async ({ body, headers, expected }) => { - const transportInterceptor: StandardLinkTransportInterceptor = ({ next, ...interceptorOptions }) => next({ - ...interceptorOptions, - request: { - ...interceptorOptions.request, - headers: { ...interceptorOptions.request.headers, ...headers }, - body: body(), - }, - }) + const transportInterceptor: StandardLinkTransportInterceptor = ({ + next, + ...interceptorOptions + }) => + next({ + ...interceptorOptions, + request: { + ...interceptorOptions.request, + headers: { ...interceptorOptions.request.headers, ...headers }, + body: body(), + }, + }) - const { link, fetch } = createLink({ pluginOptions: { threshold: 100 }, transportInterceptors: [transportInterceptor] }) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100 }, + transportInterceptors: [transportInterceptor], + }) const { link: uncompressedLink, fetch: uncompressedFetch } = createLink({ pluginOptions: { threshold: Infinity, isCompressibleContentType: () => false }, transportInterceptors: [transportInterceptor], }) await expect(link.call(['test'], undefined, { context: {} })).resolves.toEqual('OK') - await expect(uncompressedLink.call(['test'], undefined, { context: {} })).resolves.toEqual('OK') + await expect(uncompressedLink.call(['test'], undefined, { context: {} })).resolves.toEqual( + 'OK', + ) const [, init] = fetch.mock.calls[0]! const [, uncompressedInit] = uncompressedFetch.mock.calls[0]! @@ -635,8 +689,12 @@ describe('requestCompressionLinkPlugin', () => { expect(uncompressedInit.headers.get('content-encoding')).toBeNull() expect(init.headers.get('content-type')).toBe(uncompressedInit.headers.get('content-type')) - expect(init.headers.get('standard-server')).toBe(uncompressedInit.headers.get('standard-server')) - await expect(decompressStream(init.body, 'gzip')).resolves.toBe(await new Response(uncompressedInit.body).text()) + expect(init.headers.get('standard-server')).toBe( + uncompressedInit.headers.get('standard-server'), + ) + await expect(decompressStream(init.body, 'gzip')).resolves.toBe( + await new Response(uncompressedInit.body).text(), + ) }) }) @@ -658,21 +716,26 @@ describe('requestCompressionLinkPlugin', () => { describe('content-encoding header', () => { it('should not compress when content-encoding header is set', async () => { - const transportInterceptor = vi.fn>(async ({ next, ...interceptorOptions }) => { - const request = interceptorOptions.request - - return next({ - ...interceptorOptions, - request: { - ...request, - headers: { - ...request.headers, - 'content-encoding': '__CUSTOM__', + const transportInterceptor = vi.fn>( + async ({ next, ...interceptorOptions }) => { + const request = interceptorOptions.request + + return next({ + ...interceptorOptions, + request: { + ...request, + headers: { + ...request.headers, + 'content-encoding': '__CUSTOM__', + }, }, - }, - }) + }) + }, + ) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100 }, + transportInterceptors: [transportInterceptor], }) - const { link, fetch } = createLink({ pluginOptions: { threshold: 100 }, transportInterceptors: [transportInterceptor] }) const largeBlob = new Blob(['large content'.repeat(100)], { type: 'text/plain' }) await expect(link.call(['test'], largeBlob, { context: {} })).resolves.toEqual('OK') @@ -696,9 +759,9 @@ describe('requestCompressionLinkPlugin', () => { expect(init.body).toBeInstanceOf(ReadableStream) expect(init.headers?.get('content-encoding')).toBe('gzip') - await expect( - decompressStream(init.body as ReadableStream, 'gzip'), - ).resolves.toEqual(await largeBlob.text()) + await expect(decompressStream(init.body as ReadableStream, 'gzip')).resolves.toEqual( + await largeBlob.text(), + ) }) it('always compresses when threshold is 0', async () => { @@ -712,9 +775,9 @@ describe('requestCompressionLinkPlugin', () => { expect(init.body).toBeInstanceOf(ReadableStream) expect(init.headers?.get('content-encoding')).toBe('gzip') - await expect( - decompressStream(init.body as ReadableStream, 'gzip'), - ).resolves.toEqual(await smallBlob.text()) + await expect(decompressStream(init.body as ReadableStream, 'gzip')).resolves.toEqual( + await smallBlob.text(), + ) }) }) @@ -730,13 +793,15 @@ describe('requestCompressionLinkPlugin', () => { expect(init.body).toBeInstanceOf(ReadableStream) expect(init.headers?.get('content-encoding')).toBe('deflate') - await expect( - decompressStream(init.body as ReadableStream, 'deflate'), - ).resolves.toEqual(await largeBlob.text()) + await expect(decompressStream(init.body as ReadableStream, 'deflate')).resolves.toEqual( + await largeBlob.text(), + ) }) it('should use deflate-raw algorithm when specified', async () => { - const { link, fetch } = createLink({ pluginOptions: { threshold: 100, encoding: 'deflate-raw' } }) + const { link, fetch } = createLink({ + pluginOptions: { threshold: 100, encoding: 'deflate-raw' }, + }) const largeBlob = new Blob(['large content'.repeat(100)], { type: 'text/plain' }) await expect(link.call(['test'], largeBlob, { context: {} })).resolves.toEqual('OK') @@ -746,9 +811,9 @@ describe('requestCompressionLinkPlugin', () => { expect(init.body).toBeInstanceOf(ReadableStream) expect(init.headers?.get('content-encoding')).toBe('deflate-raw') - await expect( - decompressStream(init.body as ReadableStream, 'deflate-raw'), - ).resolves.toEqual(await largeBlob.text()) + await expect(decompressStream(init.body as ReadableStream, 'deflate-raw')).resolves.toEqual( + await largeBlob.text(), + ) }) }) @@ -764,7 +829,9 @@ describe('requestCompressionLinkPlugin', () => { }, }, }) - const binaryBlob = new Blob(['large content'.repeat(100)], { type: 'application/octet-stream' }) + const binaryBlob = new Blob(['large content'.repeat(100)], { + type: 'application/octet-stream', + }) await expect(link.call(['test'], binaryBlob, { context: {} })).resolves.toEqual('OK') @@ -773,9 +840,9 @@ describe('requestCompressionLinkPlugin', () => { expect(init.body).toBeInstanceOf(ReadableStream) expect(init.headers?.get('content-encoding')).toBe('gzip') - await expect( - decompressStream(init.body as ReadableStream, 'gzip'), - ).resolves.toEqual(await binaryBlob.text()) + await expect(decompressStream(init.body as ReadableStream, 'gzip')).resolves.toEqual( + await binaryBlob.text(), + ) }) it('does not compress a normally compressible content-type when the custom check rejects it', async () => { diff --git a/packages/client/src/plugins/request-compression.ts b/packages/client/src/plugins/request-compression.ts index 158103f6aa..49943adeee 100644 --- a/packages/client/src/plugins/request-compression.ts +++ b/packages/client/src/plugins/request-compression.ts @@ -1,10 +1,16 @@ -import type { StandardBodyHint, StandardHeaders } from '@standard-server/core' -import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor, StandardLinkTransportInterceptorOptions } from '../adapters/standard' -import type { ClientContext } from '../types' import { isAsyncIteratorObject, isCompressibleContentType, toArray } from '@orpc/shared' +import type { StandardBodyHint, StandardHeaders } from '@standard-server/core' import { flattenStandardHeader, generateContentDisposition } from '@standard-server/core' import { toFetchBody } from '@standard-server/fetch' +import type { + StandardLinkOptions, + StandardLinkPlugin, + StandardLinkTransportInterceptor, + StandardLinkTransportInterceptorOptions, +} from '../adapters/standard/index.ts' +import type { ClientContext } from '../types.ts' + // Rough UTF-8 estimate. Mostly ASCII text stays close to 1 byte/char; // occasional multi-byte characters increase the average. const AVG_BYTES_PER_CHAR = 1.2 @@ -33,7 +39,12 @@ export interface RequestCompressionLinkPluginOptions { * * @default isCompressibleContentType (covers common text-based formats) */ - isCompressibleContentType?: ((contentType: string | null | undefined, options: StandardLinkTransportInterceptorOptions) => boolean) | undefined + isCompressibleContentType?: + | (( + contentType: string | null | undefined, + options: StandardLinkTransportInterceptorOptions, + ) => boolean) + | undefined } /** @@ -42,7 +53,9 @@ export interface RequestCompressionLinkPluginOptions { * * @see {@link https://orpc.dev/docs/plugins/request-compression | Request Compression Plugin} */ -export class RequestCompressionLinkPlugin implements StandardLinkPlugin { +export class RequestCompressionLinkPlugin< + T extends ClientContext, +> implements StandardLinkPlugin { name = '~request-compression' /** @@ -51,8 +64,14 @@ export class RequestCompressionLinkPlugin implements St after: string[] = ['~batch'] private readonly encoding: Exclude['encoding'], undefined> - private readonly threshold: Exclude['threshold'], undefined> - private readonly isCompressibleContentType: Exclude['isCompressibleContentType'], undefined> + private readonly threshold: Exclude< + RequestCompressionLinkPluginOptions['threshold'], + undefined + > + private readonly isCompressibleContentType: Exclude< + RequestCompressionLinkPluginOptions['isCompressibleContentType'], + undefined + > constructor(options: RequestCompressionLinkPluginOptions = {}) { this.encoding = options.encoding ?? 'gzip' @@ -61,11 +80,17 @@ export class RequestCompressionLinkPlugin implements St } init(options: StandardLinkOptions): StandardLinkOptions { - const transportInterceptor: StandardLinkTransportInterceptor = async ({ next, ...interceptorOptions }) => { + const transportInterceptor: StandardLinkTransportInterceptor = async ({ + next, + ...interceptorOptions + }) => { const request = interceptorOptions.request - const contentEncoding = flattenStandardHeader(request.headers['content-encoding'])?.trim()?.toLowerCase() - if (contentEncoding !== undefined) { // already compressed, do not compress again + const contentEncoding = flattenStandardHeader(request.headers['content-encoding']) + ?.trim() + ?.toLowerCase() + if (contentEncoding !== undefined) { + // already compressed, do not compress again return next() } @@ -73,8 +98,11 @@ export class RequestCompressionLinkPlugin implements St const contentLength = Number(flattenStandardHeader(request.headers['content-length'])) if ( - (!Number.isFinite(contentLength) || contentLength >= this.threshold) - && this.isCompressibleContentType(flattenStandardHeader(request.headers['content-type']), interceptorOptions) + (!Number.isFinite(contentLength) || contentLength >= this.threshold) && + this.isCompressibleContentType( + flattenStandardHeader(request.headers['content-type']), + interceptorOptions, + ) ) { const compressedStream = request.body.pipeThrough(new CompressionStream(this.encoding)) @@ -91,19 +119,19 @@ export class RequestCompressionLinkPlugin implements St }, }) } - } - - else if (request.body instanceof Blob) { + } else if (request.body instanceof Blob) { const contentType = request.headers['content-type'] ?? request.body.type if ( - (!Number.isFinite(request.body.size) || request.body.size >= this.threshold) - && this.isCompressibleContentType(flattenStandardHeader(contentType), interceptorOptions) + (!Number.isFinite(request.body.size) || request.body.size >= this.threshold) && + this.isCompressibleContentType(flattenStandardHeader(contentType), interceptorOptions) ) { - const compressedStream = request.body.stream().pipeThrough(new CompressionStream(this.encoding)) - const contentDisposition = request.headers['content-disposition'] ?? generateContentDisposition( - request.body instanceof File ? request.body.name : 'blob', - ) + const compressedStream = request.body + .stream() + .pipeThrough(new CompressionStream(this.encoding)) + const contentDisposition = + request.headers['content-disposition'] ?? + generateContentDisposition(request.body instanceof File ? request.body.name : 'blob') return next({ ...interceptorOptions, @@ -112,7 +140,8 @@ export class RequestCompressionLinkPlugin implements St body: compressedStream, headers: { ...request.headers, - 'standard-server': request.headers['standard-server'] ?? 'file' satisfies StandardBodyHint, + 'standard-server': + request.headers['standard-server'] ?? ('file' satisfies StandardBodyHint), 'content-type': contentType, 'content-length': [], 'content-disposition': contentDisposition, @@ -121,9 +150,7 @@ export class RequestCompressionLinkPlugin implements St }, }) } - } - - else if (request.body instanceof FormData) { + } else if (request.body instanceof FormData) { const PART_OVERHEAD = 64 // approx bytes for boundary + Content-Disposition/Content-Type headers per part let contentLength = 0 @@ -131,7 +158,8 @@ export class RequestCompressionLinkPlugin implements St contentLength += PART_OVERHEAD + key.length if (value instanceof Blob) { - if (!Number.isFinite(value.size)) { // Bun-s3 can use NaN for size + if (!Number.isFinite(value.size)) { + // Bun-s3 can use NaN for size if (!this.isCompressibleContentType(value.type, interceptorOptions)) { // Unknown non-compressible part size makes the estimate unreliable contentLength = -Infinity @@ -140,14 +168,12 @@ export class RequestCompressionLinkPlugin implements St // Unknown size for compressible content - still apply compression contentLength = Infinity - } - else { + } else { contentLength += this.isCompressibleContentType(value.type, interceptorOptions) ? value.size : -value.size } - } - else { + } else { contentLength += value.length * AVG_BYTES_PER_CHAR } } @@ -171,12 +197,12 @@ export class RequestCompressionLinkPlugin implements St }, }) } - } - - else if (request.body instanceof URLSearchParams) { + } else if (request.body instanceof URLSearchParams) { const string = request.body.toString() if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) { - const compressedStream = new Blob([string]).stream().pipeThrough(new CompressionStream(this.encoding)) + const compressedStream = new Blob([string]) + .stream() + .pipeThrough(new CompressionStream(this.encoding)) const [, bodyHeaders] = toFetchBody(request.body, request.headers) return next({ @@ -194,12 +220,15 @@ export class RequestCompressionLinkPlugin implements St }, }) } - } - - else if (request.body !== undefined && !isAsyncIteratorObject(request.body)) { - const [string, bodyHeaders] = toFetchBody(request.body, request.headers) as [string, StandardHeaders] + } else if (request.body !== undefined && !isAsyncIteratorObject(request.body)) { + const [string, bodyHeaders] = toFetchBody(request.body, request.headers) as [ + string, + StandardHeaders, + ] if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) { - const compressedStream = new Blob([string]).stream().pipeThrough(new CompressionStream(this.encoding)) + const compressedStream = new Blob([string]) + .stream() + .pipeThrough(new CompressionStream(this.encoding)) return next({ ...interceptorOptions, request: { @@ -221,10 +250,7 @@ export class RequestCompressionLinkPlugin implements St return { ...options, - transportInterceptors: [ - ...toArray(options.transportInterceptors), - transportInterceptor, - ], + transportInterceptors: [...toArray(options.transportInterceptors), transportInterceptor], } } } diff --git a/packages/client/src/plugins/response-compression.test.ts b/packages/client/src/plugins/response-compression.test.ts index 07640e0c13..533396577a 100644 --- a/packages/client/src/plugins/response-compression.test.ts +++ b/packages/client/src/plugins/response-compression.test.ts @@ -1,9 +1,17 @@ -import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor } from '../adapters/standard' import { RPCLink } from '@orpc/client/fetch' import { toArray } from '@orpc/shared' -import { ResponseCompressionLinkPlugin } from './response-compression' -async function compressAsync(data: string, encoding: 'gzip' | 'deflate' | 'deflate-raw'): Promise> { +import type { + StandardLinkOptions, + StandardLinkPlugin, + StandardLinkTransportInterceptor, +} from '../adapters/standard/index.ts' +import { ResponseCompressionLinkPlugin } from './response-compression.ts' + +async function compressAsync( + data: string, + encoding: 'gzip' | 'deflate' | 'deflate-raw', +): Promise> { const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(encoding)) const buffer = await new Response(stream).arrayBuffer() return new Uint8Array(buffer) @@ -13,15 +21,20 @@ beforeEach(() => { vi.clearAllMocks() }) -function createLink(options: { - pluginOptions?: ConstructorParameters[0] - plugins?: StandardLinkPlugin[] - transportInterceptors?: StandardLinkTransportInterceptor[] - fetchImpl?: (url: string, init: { body: any, headers: Headers }) => Promise -} = {}) { +function createLink( + options: { + pluginOptions?: ConstructorParameters[0] + plugins?: StandardLinkPlugin[] + transportInterceptors?: StandardLinkTransportInterceptor[] + fetchImpl?: (url: string, init: { body: any; headers: Headers }) => Promise + } = {}, +) { const fetch = vi.fn( - options.fetchImpl ?? (async (_url: string, _init: { body: any, headers: Headers }) => - new Response(JSON.stringify({ json: 'OK' }), { headers: { 'content-type': 'application/json' } })), + options.fetchImpl ?? + (async (_url: string, _init: { body: any; headers: Headers }) => + new Response(JSON.stringify({ json: 'OK' }), { + headers: { 'content-type': 'application/json' }, + })), ) const link = new RPCLink({ @@ -60,38 +73,43 @@ describe('responseCompressionLinkPlugin', () => { expect(init.headers?.get('accept-encoding')).toBe('deflate, gzip') }) - it.each( - ['gzip', 'deflate', 'deflate-raw'] as const, - )('decompresses response body when content-encoding is %s', async (encoding) => { - const payload = JSON.stringify({ json: 'OK' }) - const compressed = await compressAsync(payload, encoding) - - const { link } = createLink({ - fetchImpl: async () => new Response(compressed, { - headers: { - 'content-type': 'application/json', - 'content-encoding': encoding, - }, - }), - }) - - await expect(link.call(['test'], undefined, { context: {} })).resolves.toEqual('OK') - }) + it.each(['gzip', 'deflate', 'deflate-raw'] as const)( + 'decompresses response body when content-encoding is %s', + async (encoding) => { + const payload = JSON.stringify({ json: 'OK' }) + const compressed = await compressAsync(payload, encoding) + + const { link } = createLink({ + fetchImpl: async () => + new Response(compressed, { + headers: { + 'content-type': 'application/json', + 'content-encoding': encoding, + }, + }), + }) + + await expect(link.call(['test'], undefined, { context: {} })).resolves.toEqual('OK') + }, + ) it('decompresses response body when multiple content-encodings are applied', async () => { const payload = JSON.stringify({ json: 'OK' }) // Content-Encoding: deflate, gzip means gzip applied last const deflatedBytes = await compressAsync(payload, 'deflate') - const gzippedStream = new Blob([deflatedBytes]).stream().pipeThrough(new CompressionStream('gzip')) + const gzippedStream = new Blob([deflatedBytes]) + .stream() + .pipeThrough(new CompressionStream('gzip')) const multiCompressed = new Uint8Array(await new Response(gzippedStream).arrayBuffer()) const { link } = createLink({ - fetchImpl: async () => new Response(multiCompressed, { - headers: { - 'content-type': 'application/json', - 'content-encoding': 'deflate, gzip', - }, - }), + fetchImpl: async () => + new Response(multiCompressed, { + headers: { + 'content-type': 'application/json', + 'content-encoding': 'deflate, gzip', + }, + }), }) await expect(link.call(['test'], undefined, { context: {} })).resolves.toEqual('OK') @@ -100,16 +118,21 @@ describe('responseCompressionLinkPlugin', () => { it('decompresses response body when 5 content-encodings are applied', async () => { let body: Uint8Array = await compressAsync(JSON.stringify({ json: 'OK' }), 'gzip') for (let i = 1; i < 5; i++) { - body = new Uint8Array(await new Response(new Blob([body]).stream().pipeThrough(new CompressionStream('gzip'))).arrayBuffer()) + body = new Uint8Array( + await new Response( + new Blob([body]).stream().pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer(), + ) } const { link } = createLink({ - fetchImpl: async () => new Response(body, { - headers: { - 'content-type': 'application/json', - 'content-encoding': Array.from({ length: 5 }).fill('gzip').join(', '), - }, - }), + fetchImpl: async () => + new Response(body, { + headers: { + 'content-type': 'application/json', + 'content-encoding': Array.from({ length: 5 }).fill('gzip').join(', '), + }, + }), }) await expect(link.call(['test'], undefined, { context: {} })).resolves.toEqual('OK') @@ -117,12 +140,13 @@ describe('responseCompressionLinkPlugin', () => { it('rejects response body when more than 5 content-encodings are applied', async () => { const { link } = createLink({ - fetchImpl: async () => new Response('irrelevant', { - headers: { - 'content-type': 'application/json', - 'content-encoding': Array.from({ length: 6 }).fill('gzip').join(', '), - }, - }), + fetchImpl: async () => + new Response('irrelevant', { + headers: { + 'content-type': 'application/json', + 'content-encoding': Array.from({ length: 6 }).fill('gzip').join(', '), + }, + }), }) const promise = link.call(['test'], undefined, { context: {} }) @@ -135,12 +159,13 @@ describe('responseCompressionLinkPlugin', () => { const payload = JSON.stringify({ json: 'OK' }) const { link } = createLink({ - fetchImpl: async () => new Response(payload, { - headers: { - 'content-type': 'application/json', - 'content-encoding': 'br', - }, - }), + fetchImpl: async () => + new Response(payload, { + headers: { + 'content-type': 'application/json', + 'content-encoding': 'br', + }, + }), }) // Body is not decompressed; JSON parse still works because body was never compressed @@ -158,12 +183,13 @@ describe('responseCompressionLinkPlugin', () => { const compressed = await compressAsync(payload, 'gzip') const { link } = createLink({ - fetchImpl: async () => new Response(compressed, { - headers: { - 'content-type': 'application/json', - 'content-encoding': 'gzip, br', - }, - }), + fetchImpl: async () => + new Response(compressed, { + headers: { + 'content-type': 'application/json', + 'content-encoding': 'gzip, br', + }, + }), }) // Partial decode is skipped; body stays compressed → deserialize fails diff --git a/packages/client/src/plugins/response-compression.ts b/packages/client/src/plugins/response-compression.ts index b10c4abe20..2caf1869b1 100644 --- a/packages/client/src/plugins/response-compression.ts +++ b/packages/client/src/plugins/response-compression.ts @@ -1,9 +1,14 @@ -import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor } from '../adapters/standard' -import type { ClientContext } from '../types' import { toArray } from '@orpc/shared' import { flattenStandardHeader } from '@standard-server/core' import { toFetchHeaders, toStandardBody } from '@standard-server/fetch' +import type { + StandardLinkOptions, + StandardLinkPlugin, + StandardLinkTransportInterceptor, +} from '../adapters/standard/index.ts' +import type { ClientContext } from '../types.ts' + /** * Each coding adds a decompressor to the chain, so an unbounded list lets a tiny * response cost unbounded CPU and memory. Same limit as undici and curl. @@ -27,7 +32,9 @@ export interface ResponseCompressionLinkPluginOptions<_T extends ClientContext> * * @see {@link https://orpc.dev/docs/plugins/response-compression | Response Compression Plugin} */ -export class ResponseCompressionLinkPlugin implements StandardLinkPlugin { +export class ResponseCompressionLinkPlugin< + T extends ClientContext, +> implements StandardLinkPlugin { name = '~response-compression' /** @@ -35,7 +42,10 @@ export class ResponseCompressionLinkPlugin implements S */ after: string[] = ['~batch'] - private readonly encodings: Exclude['encodings'], undefined> + private readonly encodings: Exclude< + ResponseCompressionLinkPluginOptions['encodings'], + undefined + > constructor(options: ResponseCompressionLinkPluginOptions = {}) { this.encodings = options.encodings ?? ['gzip', 'deflate'] @@ -44,7 +54,10 @@ export class ResponseCompressionLinkPlugin implements S init(options: StandardLinkOptions): StandardLinkOptions { const acceptEncodingHeader = this.encodings.join(', ') - const transportInterceptor: StandardLinkTransportInterceptor = async ({ next, ...interceptorOptions }) => { + const transportInterceptor: StandardLinkTransportInterceptor = async ({ + next, + ...interceptorOptions + }) => { const response = await next({ ...interceptorOptions, request: { @@ -103,10 +116,7 @@ export class ResponseCompressionLinkPlugin implements S return { ...options, - transportInterceptors: [ - ...toArray(options.transportInterceptors), - transportInterceptor, - ], + transportInterceptors: [...toArray(options.transportInterceptors), transportInterceptor], } } } @@ -128,7 +138,5 @@ function parseContentEncodings(header: string | undefined): string[] { return [] } - return header - .split(',') - .map(part => part.trim().toLowerCase()) + return header.split(',').map((part) => part.trim().toLowerCase()) } diff --git a/packages/client/src/plugins/retry-after.test.ts b/packages/client/src/plugins/retry-after.test.ts index 0de65d66b3..9c1844dacf 100644 --- a/packages/client/src/plugins/retry-after.test.ts +++ b/packages/client/src/plugins/retry-after.test.ts @@ -1,17 +1,21 @@ import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' -import { StandardLink } from '../adapters/standard' -import { RetryAfterLinkPlugin } from './retry-after' + +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard/index.ts' +import { StandardLink } from '../adapters/standard/index.ts' +import { RetryAfterLinkPlugin } from './retry-after.ts' function makeCodec(): StandardLinkCodec { return { - encodeInput: vi.fn(async () => ({ - method: 'POST', - url: '/test', - headers: {}, - body: undefined, - } satisfies StandardRequest)), - decodeResponse: vi.fn(async response => ({ + 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(), })), @@ -20,11 +24,14 @@ function makeCodec(): StandardLinkCodec { function makeTransport(): StandardLinkTransport { return { - send: vi.fn(async () => ({ - status: 200, - headers: {}, - resolveBody: async () => 'success', - } satisfies StandardLazyResponse)), + send: vi.fn( + async () => + ({ + status: 200, + headers: {}, + resolveBody: async () => 'success', + }) satisfies StandardLazyResponse, + ), } } @@ -40,37 +47,40 @@ afterEach(() => { describe('retryAfterLinkPlugin', () => { describe('core behavior', () => { - it.each([429, 503])('should retry on %i with retry-after header and succeed', async (status) => { - const codec = makeCodec() - const transport = makeTransport() + 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 - vi.mocked(transport.send).mockImplementation(async () => { - callCount++ - if (callCount === 1) { + let callCount = 0 + vi.mocked(transport.send).mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return { + status, + headers: { 'retry-after': '2' }, + resolveBody: async () => 'rate limited', + } satisfies StandardLazyResponse + } return { - status, - headers: { 'retry-after': '2' }, - resolveBody: async () => 'rate limited', + status: 200, + headers: {}, + resolveBody: async () => 'success', } satisfies StandardLazyResponse - } - return { - status: 200, - headers: {}, - resolveBody: async () => 'success', - } satisfies StandardLazyResponse - }) + }) - const link = new StandardLink(codec, transport, { - plugins: [new RetryAfterLinkPlugin()], - }) + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin()], + }) - const promise = link.call(['test'], 'input', { context: {} }) - await vi.runAllTimersAsync() + const promise = link.call(['test'], 'input', { context: {} }) + await vi.runAllTimersAsync() - expect(await promise).toBe('success') - expect(transport.send).toHaveBeenCalledTimes(2) - }) + expect(await promise).toBe('success') + expect(transport.send).toHaveBeenCalledTimes(2) + }, + ) it('should not retry without retry-after header or on non-retryable status', async () => { const testCases = [ @@ -263,7 +273,13 @@ describe('retryAfterLinkPlugin', () => { describe('request body', () => { it.each([ ['ReadableStream', () => new Blob(['data']).stream()], - ['AsyncIteratorObject', () => (async function* () { yield 'data' })()], + [ + 'AsyncIteratorObject', + () => + (async function* () { + yield 'data' + })(), + ], ])('should not retry a %s body, since it cannot be resent', async (_, createBody) => { const codec = makeCodec() const transport = makeTransport() @@ -290,16 +306,23 @@ describe('retryAfterLinkPlugin', () => { expect(result).toBe('rate limited') expect(transport.send).toHaveBeenCalledTimes(1) - expect(transport.send).toHaveBeenCalledWith(expect.objectContaining({ body }), ['test'], expect.anything()) + expect(transport.send).toHaveBeenCalledWith( + expect.objectContaining({ body }), + ['test'], + expect.anything(), + ) }) it.each([ ['Blob', () => new Blob(['data'])], - ['FormData', () => { - const form = new FormData() - form.append('file', new Blob(['data'])) - return form - }], + [ + 'FormData', + () => { + const form = new FormData() + form.append('file', new Blob(['data'])) + return form + }, + ], ])('should retry a %s body, since it can be resent', async (_, createBody) => { const codec = makeCodec() const transport = makeTransport() diff --git a/packages/client/src/plugins/retry-after.ts b/packages/client/src/plugins/retry-after.ts index 86a8a723b3..d8be273e68 100644 --- a/packages/client/src/plugins/retry-after.ts +++ b/packages/client/src/plugins/retry-after.ts @@ -1,10 +1,16 @@ import type { Value } from '@orpc/shared' -import type { StandardLazyResponse } from '@standard-server/core' -import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor, StandardLinkTransportInterceptorOptions } from '../adapters/standard' -import type { ClientContext } from '../types' import { isAsyncIteratorObject, sleep, toArray, value } from '@orpc/shared' +import type { StandardLazyResponse } from '@standard-server/core' import { flattenStandardHeader } from '@standard-server/core' -import { COMMON_ERROR_STATUS_MAP } from '../error' + +import type { + StandardLinkOptions, + StandardLinkPlugin, + StandardLinkTransportInterceptor, + StandardLinkTransportInterceptorOptions, +} from '../adapters/standard/index.ts' +import { COMMON_ERROR_STATUS_MAP } from '../error.ts' +import type { ClientContext } from '../types.ts' export interface RetryAfterLinkPluginOptions { /** @@ -12,30 +18,36 @@ export interface RetryAfterLinkPluginOptions { * * @default ((response) => response.status === 429 || response.status === 503) */ - condition?: Value, - ]> | undefined + condition?: + | Value< + boolean, + [response: StandardLazyResponse, options: StandardLinkTransportInterceptorOptions] + > + | undefined /** * Maximum attempts before giving up retries. * * @default 3 */ - maxAttempts?: Value, - ]> | undefined + maxAttempts?: + | Value< + number, + [response: StandardLazyResponse, options: StandardLinkTransportInterceptorOptions] + > + | undefined /** * Maximum timeout in milliseconds to wait before giving up retries. * * @default 5 * 60 * 1000 (5 minutes) */ - timeout?: Value, - ]> | undefined + timeout?: + | Value< + number, + [response: StandardLazyResponse, options: StandardLinkTransportInterceptorOptions] + > + | undefined } /** @@ -55,11 +67,11 @@ export class RetryAfterLinkPlugin implements StandardLi name = '~retry-after' constructor(options: RetryAfterLinkPluginOptions = {}) { - this.condition = options.condition ?? ( - response => - response.status === COMMON_ERROR_STATUS_MAP.TOO_MANY_REQUESTS - || response.status === COMMON_ERROR_STATUS_MAP.SERVICE_UNAVAILABLE - ) + this.condition = + options.condition ?? + ((response) => + 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 @@ -107,8 +119,7 @@ export class RetryAfterLinkPlugin implements StandardLi try { await sleep(retryAfterMs, { signal: interceptorOptions.signal }) - } - catch { + } catch { // can throw if the signal is aborted while sleeping } @@ -118,7 +129,10 @@ export class RetryAfterLinkPlugin implements StandardLi } } - return { ...options, transportInterceptors: [interceptor, ...toArray(options.transportInterceptors)] } + return { + ...options, + transportInterceptors: [interceptor, ...toArray(options.transportInterceptors)], + } } } diff --git a/packages/client/src/plugins/retry.test.ts b/packages/client/src/plugins/retry.test.ts index 7d73e78b48..c31fbfc4f4 100644 --- a/packages/client/src/plugins/retry.test.ts +++ b/packages/client/src/plugins/retry.test.ts @@ -1,11 +1,12 @@ -import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' -import type { RetryLinkPluginContext } from './retry' import { promiseWithResolvers } from '@orpc/shared' +import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' import { withEventMeta } from '@standard-server/core' -import { StandardLink } from '../adapters/standard' -import { MalformedResponseError, ORPCError, ResponseError } from '../error' -import { isRetryableError, RetryLinkPlugin } from './retry' + +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard/index.ts' +import { StandardLink } from '../adapters/standard/index.ts' +import { MalformedResponseError, ORPCError, ResponseError } from '../error.ts' +import type { RetryLinkPluginContext } from './retry.ts' +import { isRetryableError, RetryLinkPlugin } from './retry.ts' interface TestContext extends RetryLinkPluginContext { tag?: string @@ -17,23 +18,29 @@ function makeResponse(status: number) { function makeCodec(): StandardLinkCodec { return { - encodeInput: vi.fn(async () => ({ - method: 'POST', - url: '/test', - headers: {}, - body: undefined, - } satisfies StandardRequest)), + 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)), + send: vi.fn( + async () => + ({ + status: 200, + headers: {}, + resolveBody: async () => undefined, + }) satisfies StandardLazyResponse, + ), } } @@ -53,17 +60,18 @@ describe('isRetryableError', () => { ])('errors from a %i response -> %s', (status, expected) => { const response = makeResponse(status) - expect(isRetryableError(new ORPCError('ERROR', { cause: new ResponseError({ response }) }))).toBe(expected) + expect( + isRetryableError(new ORPCError('ERROR', { cause: new ResponseError({ response }) })), + ).toBe(expected) expect(isRetryableError(new MalformedResponseError({ response }))).toBe(expected) }) - it.each([ - new ORPCError('BAD_REQUEST'), - new TypeError('Failed to fetch'), - 'FAIL', - ])('errors without a response -> true (%s)', (error) => { - expect(isRetryableError(error)).toBe(true) - }) + it.each([new ORPCError('BAD_REQUEST'), new TypeError('Failed to fetch'), 'FAIL'])( + 'errors without a response -> true (%s)', + (error) => { + expect(isRetryableError(error)).toBe(true) + }, + ) }) describe('retryLinkPlugin', () => { @@ -77,7 +85,9 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: {} })).rejects.toThrow('FAIL') + await expect( + link.call(['planet', 'create'], { name: 'Earth' }, { context: {} }), + ).rejects.toThrow('FAIL') expect(codec.decodeResponse).toHaveBeenCalledTimes(1) }) @@ -92,7 +102,9 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 0 } })).rejects.toThrow('FAIL') + await expect( + link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 0 } }), + ).rejects.toThrow('FAIL') expect(codec.decodeResponse).toHaveBeenCalledTimes(2) }) @@ -109,7 +121,13 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 5, retryDelay: 0, shouldRetry } })).rejects.toThrow('FAIL') + await expect( + link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 5, retryDelay: 0, shouldRetry } }, + ), + ).rejects.toThrow('FAIL') expect(shouldRetry).toHaveBeenCalledTimes(1) expect(codec.decodeResponse).toHaveBeenCalledTimes(1) @@ -119,14 +137,18 @@ describe('retryLinkPlugin', () => { const codec = makeCodec() const transport = makeTransport() - const error = new ORPCError('BAD_REQUEST', { cause: new ResponseError({ response: makeResponse(400) }) }) + const error = new ORPCError('BAD_REQUEST', { + cause: new ResponseError({ response: makeResponse(400) }), + }) vi.mocked(codec.decodeResponse).mockRejectedValue(error) const link = new StandardLink(codec, transport, { plugins: [new RetryLinkPlugin()], }) - await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 3, retryDelay: 0 } })).rejects.toBe(error) + await expect( + link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 3, retryDelay: 0 } }), + ).rejects.toBe(error) expect(codec.decodeResponse).toHaveBeenCalledTimes(1) }) @@ -136,14 +158,22 @@ describe('retryLinkPlugin', () => { const transport = makeTransport() vi.mocked(codec.decodeResponse) - .mockRejectedValueOnce(new ORPCError('NOT_FOUND', { cause: new ResponseError({ response: makeResponse(404) }) })) + .mockRejectedValueOnce( + new ORPCError('NOT_FOUND', { cause: new ResponseError({ response: makeResponse(404) }) }), + ) .mockResolvedValueOnce({ kind: 'output', output: 'OK' }) const link = new StandardLink(codec, transport, { plugins: [new RetryLinkPlugin()], }) - await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 3, retryDelay: 0, shouldRetry: true } })).resolves.toBe('OK') + await expect( + link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 3, retryDelay: 0, shouldRetry: true } }, + ), + ).resolves.toBe('OK') expect(codec.decodeResponse).toHaveBeenCalledTimes(2) }) @@ -170,7 +200,13 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 3, retryDelay: 0, onRetry } })).resolves.toBe('OK') + await expect( + link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 3, retryDelay: 0, onRetry } }, + ), + ).resolves.toBe('OK') expect(onRetry).toHaveBeenCalledTimes(2) expect(clean).toHaveBeenCalledTimes(2) @@ -191,7 +227,13 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 3, retryDelay: 0 }, signal: controller.signal })).rejects.toThrow('AbortError') + await expect( + link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 3, retryDelay: 0 }, signal: controller.signal }, + ), + ).rejects.toThrow('AbortError') expect(transport.send).toHaveBeenCalledTimes(1) }) @@ -208,7 +250,9 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin({ default: { retry: 1, retryDelay: 0 } })], }) - await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: {} })).resolves.toBe('OK') + await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: {} })).resolves.toBe( + 'OK', + ) expect(codec.decodeResponse).toHaveBeenCalledTimes(2) }) @@ -286,7 +330,13 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay, onRetry } })).rejects.toThrow('DELAY_FAIL') + await expect( + link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 1, retryDelay, onRetry } }, + ), + ).rejects.toThrow('DELAY_FAIL') expect(retryDelay).toHaveBeenCalledTimes(1) expect(onRetry).not.toHaveBeenCalled() @@ -326,13 +376,21 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, shouldRetry }, lastEventId: 'init-id' }) as AsyncIterator + const iterator = (await link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 1, shouldRetry }, lastEventId: 'init-id' }, + )) as AsyncIterator await expect(iterator.next()).resolves.toEqual({ done: false, value: { phase: 'first' } }) await expect(iterator.next()).resolves.toEqual({ done: false, value: { phase: 'second' } }) - 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(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(shouldRetry).toHaveBeenCalledTimes(1) expect(shouldRetry).toHaveBeenCalledWith(expect.objectContaining({ lastEventRetry: 0 })) @@ -362,7 +420,11 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 0 } }) as AsyncIterator + const iterator = (await link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 1, retryDelay: 0 } }, + )) as AsyncIterator await expect(iterator.next()).rejects.toBeInstanceOf(TypeError) }) @@ -379,8 +441,7 @@ describe('retryLinkPlugin', () => { try { yield 1 yield 2 - } - finally { + } finally { cleanup() } })(), @@ -390,7 +451,11 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 0 } }) as AsyncIterator + const iterator = (await link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 1, retryDelay: 0 } }, + )) as AsyncIterator await iterator.next() await iterator.return?.() @@ -420,8 +485,7 @@ describe('retryLinkPlugin', () => { output: (async function* () { try { throw new Error('ITER_FAIL') - } - finally { + } finally { cleanup() } })(), @@ -448,7 +512,11 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 50 } }) as AsyncIterator + const iterator = (await link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 1, retryDelay: 50 } }, + )) as AsyncIterator const nextPromise = iterator.next() const nextExpectation = expect(nextPromise).resolves.toEqual({ done: true, value: undefined }) @@ -486,7 +554,9 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - const iterator = await link.call(['stream'], undefined, { context: { retry: Number.POSITIVE_INFINITY, retryDelay: 10, onRetry } }) as AsyncIterator + const iterator = (await link.call(['stream'], undefined, { + context: { retry: Number.POSITIVE_INFINITY, retryDelay: 10, onRetry }, + })) as AsyncIterator const nextPromise = iterator.next() @@ -525,7 +595,9 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - const iterator = await link.call(['stream'], undefined, { context: { retry: 3, retryDelay: 1000, onRetry } }) as AsyncIterator + const iterator = (await link.call(['stream'], undefined, { + context: { retry: 3, retryDelay: 1000, onRetry }, + })) as AsyncIterator const nextPromise = iterator.next() @@ -570,7 +642,11 @@ describe('retryLinkPlugin', () => { plugins: [new RetryLinkPlugin()], }) - const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 0 } }) as AsyncIterator + const iterator = (await link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 1, retryDelay: 0 } }, + )) as AsyncIterator expect((iterator as any).specialProperty).toEqual('specialValue:1') await expect(iterator.next()).rejects.toThrow('ITER_FAIL') diff --git a/packages/client/src/plugins/retry.ts b/packages/client/src/plugins/retry.ts index 750271c526..0cbbc1db8c 100644 --- a/packages/client/src/plugins/retry.ts +++ b/packages/client/src/plugins/retry.ts @@ -1,11 +1,26 @@ import type { Promisable, Value } from '@orpc/shared' -import type { StandardLinkInterceptor, StandardLinkInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '../adapters/standard' -import type { ClientContext } from '../types' -import { AsyncIteratorClass, isAsyncIteratorObject, override, sleep, toArray, value } from '@orpc/shared' +import { + AsyncIteratorClass, + isAsyncIteratorObject, + override, + sleep, + toArray, + value, +} from '@orpc/shared' import { getEventMeta } from '@standard-server/core' -import { COMMON_ERROR_STATUS_MAP, ORPCError, ResponseError } from '../error' -export interface RetryLinkPluginAttemptOptions extends StandardLinkInterceptorOptions { +import type { + StandardLinkInterceptor, + StandardLinkInterceptorOptions, + StandardLinkOptions, + StandardLinkPlugin, +} from '../adapters/standard/index.ts' +import { COMMON_ERROR_STATUS_MAP, ORPCError, ResponseError } from '../error.ts' +import type { ClientContext } from '../types.ts' + +export interface RetryLinkPluginAttemptOptions< + T extends RetryLinkPluginContext, +> extends StandardLinkInterceptorOptions { /** * Latest retry delay advertised by the server via event metadata. */ @@ -35,7 +50,12 @@ export interface RetryLinkPluginContext { * * @default 0 */ - retry?: Value, [Omit, 'next'>]> | undefined + retry?: + | Value< + Promisable, + [Omit, 'next'>] + > + | undefined /** * Delay (in ms) before retrying. @@ -45,19 +65,27 @@ export interface RetryLinkPluginContext { * * @default (o) => o.lastEventRetry ?? 2000 */ - retryDelay?: Value, [RetryLinkPluginAttemptOptions]> | undefined + retryDelay?: + | Value, [RetryLinkPluginAttemptOptions]> + | undefined /** * Determine whether to retry. * * @default (o) => isRetryableError(o.error) */ - shouldRetry?: Value, [RetryLinkPluginAttemptOptions]> | undefined + shouldRetry?: + | Value, [RetryLinkPluginAttemptOptions]> + | undefined /** * Hook called before each retry. Can return a cleanup callback. */ - onRetry?: ((options: RetryLinkPluginAttemptOptions) => void | ((isSuccess: boolean) => void)) | undefined + onRetry?: + | (( + options: RetryLinkPluginAttemptOptions, + ) => void | ((isSuccess: boolean) => void)) + | undefined } export interface RetryLinkPluginOptions<_T extends RetryLinkPluginContext> { @@ -82,10 +110,12 @@ export function isRetryableError(error: unknown): boolean { const { status } = responseError.response - return status < 400 - || status >= 500 - || status === COMMON_ERROR_STATUS_MAP.TIMEOUT - || status === COMMON_ERROR_STATUS_MAP.TOO_MANY_REQUESTS + return ( + status < 400 || + status >= 500 || + status === COMMON_ERROR_STATUS_MAP.TIMEOUT || + status === COMMON_ERROR_STATUS_MAP.TOO_MANY_REQUESTS + ) } /** @@ -97,7 +127,9 @@ export function isRetryableError(error: unknown): boolean { * * @see {@link https://orpc.dev/docs/plugins/retry | Retry Plugin} */ -export class RetryLinkPlugin implements StandardLinkPlugin { +export class RetryLinkPlugin< + T extends RetryLinkPluginContext & ClientContext, +> implements StandardLinkPlugin { private readonly defaultRetry: Exclude private readonly defaultRetryDelay: Exclude private readonly defaultShouldRetry: Exclude @@ -107,18 +139,15 @@ export class RetryLinkPlugin i constructor(options: RetryLinkPluginOptions = {}) { this.defaultRetry = options.default?.retry ?? 0 - this.defaultRetryDelay = options.default?.retryDelay ?? (o => o.lastEventRetry ?? 2000) - this.defaultShouldRetry = options.default?.shouldRetry ?? (o => isRetryableError(o.error)) + this.defaultRetryDelay = options.default?.retryDelay ?? ((o) => o.lastEventRetry ?? 2000) + this.defaultShouldRetry = options.default?.shouldRetry ?? ((o) => isRetryableError(o.error)) this.defaultOnRetry = options.default?.onRetry } init(options: StandardLinkOptions): StandardLinkOptions { const interceptor: StandardLinkInterceptor = async (interceptorOptions) => { const { next, ...callOptions } = interceptorOptions - const maxAttempts = await value( - callOptions.context.retry ?? this.defaultRetry, - callOptions, - ) + const maxAttempts = await value(callOptions.context.retry ?? this.defaultRetry, callOptions) const retryDelay = callOptions.context.retryDelay ?? this.defaultRetryDelay const shouldRetry = callOptions.context.shouldRetry ?? this.defaultShouldRetry @@ -153,10 +182,7 @@ export class RetryLinkPlugin i lastEventRetry, } - const shouldRetryBool = await value( - shouldRetry, - attemptOptions, - ) + const shouldRetryBool = await value(shouldRetry, attemptOptions) if (!shouldRetryBool) { throw currentError.error @@ -179,15 +205,13 @@ export class RetryLinkPlugin i currentError = undefined return await next(updatedCallOptions) - } - catch (error) { + } catch (error) { currentError = { error } if (updatedCallOptions.signal?.aborted) { throw error } - } - finally { + } finally { callback?.(!currentError) callback = undefined } @@ -202,48 +226,51 @@ export class RetryLinkPlugin i let current = output - 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 - - return item - } - catch (error) { - const meta = getEventMeta(error) - - lastEventId = meta?.id ?? lastEventId - lastEventRetry = meta?.retry ?? lastEventRetry - - const asyncIteratorObject = await callNext({ error }) - if (!isAsyncIteratorObject(asyncIteratorObject)) { - throw new TypeError( - 'RetryLinkPlugin: Expected an AsyncIteratorObject, got a different type.', - ) - } - - current = asyncIteratorObject - - if (isIteratorAborted) { - await current.return?.() - throw error + 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 + + return item + } catch (error) { + const meta = getEventMeta(error) + + lastEventId = meta?.id ?? lastEventId + lastEventRetry = meta?.retry ?? lastEventRetry + + const asyncIteratorObject = await callNext({ error }) + if (!isAsyncIteratorObject(asyncIteratorObject)) { + throw new TypeError( + 'RetryLinkPlugin: Expected an AsyncIteratorObject, got a different type.', + { cause: error }, + ) + } + + current = asyncIteratorObject + + if (isIteratorAborted) { + await current.return?.() + throw error + } } } - } - }, - async ({ kind }) => { - isIteratorAborted = true + }, + async ({ kind }) => { + isIteratorAborted = true - if (kind === 'cancelled') { - await current.return?.() - } - }, - )) + if (kind === 'cancelled') { + await current.return?.() + } + }, + ), + ) } return { ...options, interceptors: [interceptor, ...toArray(options.interceptors)] } diff --git a/packages/client/src/plugins/timeout.test.ts b/packages/client/src/plugins/timeout.test.ts index 3a8a0430d1..04071b04ef 100644 --- a/packages/client/src/plugins/timeout.test.ts +++ b/packages/client/src/plugins/timeout.test.ts @@ -1,18 +1,22 @@ -import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' import { AbortError } from '@orpc/shared' -import { StandardLink } from '../adapters/standard' -import { TimeoutLinkPlugin } from './timeout' +import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' + +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard/index.ts' +import { StandardLink } from '../adapters/standard/index.ts' +import { TimeoutLinkPlugin } from './timeout.ts' function makeCodec(): StandardLinkCodec { return { - encodeInput: vi.fn(async () => ({ - method: 'POST', - url: '/test', - headers: {}, - body: undefined, - } satisfies StandardRequest)), - decodeResponse: vi.fn(async response => ({ + 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(), })), @@ -21,19 +25,27 @@ function makeCodec(): StandardLinkCodec { function makeTransport(): StandardLinkTransport { return { - send: vi.fn(async () => ({ - status: 200, - headers: {}, - resolveBody: async () => 'success', - } satisfies StandardLazyResponse)), + send: vi.fn( + async () => + ({ + status: 200, + headers: {}, + resolveBody: async () => 'success', + }) satisfies StandardLazyResponse, + ), } } function makeAbortableTransport(): StandardLinkTransport { return { - send: vi.fn((request, path, options) => new Promise((_, reject) => { - options.signal?.addEventListener('abort', () => reject(options.signal!.reason), { once: true }) - })), + send: vi.fn( + (request, path, options) => + new Promise((_, reject) => { + options.signal?.addEventListener('abort', () => reject(options.signal!.reason), { + once: true, + }) + }), + ), } } @@ -79,9 +91,8 @@ describe('timeoutLinkPlugin', () => { expect(vi.getTimerCount()).toBe(1) await vi.advanceTimersByTimeAsync(1) - await expect(promise).rejects.toSatisfy(error => - error instanceof AbortError - && error.message === 'Request timed out after 1000ms', + await expect(promise).rejects.toSatisfy( + (error) => error instanceof AbortError && error.message === 'Request timed out after 1000ms', ) }) @@ -102,24 +113,33 @@ describe('timeoutLinkPlugin', () => { await expect(promise).rejects.toBeInstanceOf(AbortError) expect(timeout).toHaveBeenCalledTimes(1) - expect(timeout).toHaveBeenCalledWith(expect.objectContaining({ - path: ['test'], - input: 'input', - context: { timeout: 1000 }, - })) + expect(timeout).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['test'], + input: 'input', + context: { timeout: 1000 }, + }), + ) }) it.each([null, undefined])('should disable timeout when value is %s', async (timeout) => { const codec = makeCodec() const transport = makeTransport() - vi.mocked(transport.send).mockImplementation(() => new Promise((resolve) => { - setTimeout(() => resolve({ - status: 200, - headers: {}, - resolveBody: async () => 'success', - } satisfies StandardLazyResponse), 100_000) - })) + vi.mocked(transport.send).mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + status: 200, + headers: {}, + resolveBody: async () => 'success', + } satisfies StandardLazyResponse), + 100_000, + ) + }), + ) const link = new StandardLink(codec, transport, { plugins: [new TimeoutLinkPlugin({ timeout })], @@ -166,9 +186,8 @@ describe('timeoutLinkPlugin', () => { promise.catch(() => {}) await vi.advanceTimersByTimeAsync(1000) - await expect(promise).rejects.toSatisfy(error => - error instanceof AbortError - && error.message === 'Request timed out after 1000ms', + await expect(promise).rejects.toSatisfy( + (error) => error instanceof AbortError && error.message === 'Request timed out after 1000ms', ) }) }) diff --git a/packages/client/src/plugins/timeout.ts b/packages/client/src/plugins/timeout.ts index 39eae5081f..94c65f812c 100644 --- a/packages/client/src/plugins/timeout.ts +++ b/packages/client/src/plugins/timeout.ts @@ -1,8 +1,14 @@ import type { Value } from '@orpc/shared' -import type { StandardLinkInterceptor, StandardLinkInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '../adapters/standard' -import type { ClientContext } from '../types' import { AbortError, anyAbortSignal, toArray, value } from '@orpc/shared' +import type { + StandardLinkInterceptor, + StandardLinkInterceptorOptions, + StandardLinkOptions, + StandardLinkPlugin, +} from '../adapters/standard/index.ts' +import type { ClientContext } from '../types.ts' + export interface TimeoutLinkPluginOptions { /** * Timeout in milliseconds before the request is aborted. @@ -47,8 +53,7 @@ export class TimeoutLinkPlugin implements StandardLinkP try { return await interceptorOptions.next({ ...interceptorOptions, signal }) - } - finally { + } finally { clearTimeout(timeoutId) } } diff --git a/packages/client/src/rpc-json-serializer.test.ts b/packages/client/src/rpc-json-serializer.test.ts index 688986c196..9b306035a7 100644 --- a/packages/client/src/rpc-json-serializer.test.ts +++ b/packages/client/src/rpc-json-serializer.test.ts @@ -1,6 +1,6 @@ -import type { RPCJsonSerialization } from './rpc-json-serializer' -import { builtInRPCSupportDataTypes } from '../../../tests/rpc/__shared__/built-in-support-data-types' -import { RPCJsonSerializer } from './rpc-json-serializer' +import { builtInRPCSupportDataTypes } from '../../../tests/rpc/__shared__/built-in-support-data-types.ts' +import type { RPCJsonSerialization } from './rpc-json-serializer.ts' +import { RPCJsonSerializer } from './rpc-json-serializer.ts' class Person { constructor( @@ -20,7 +20,7 @@ class Person2 { constructor( public name: string, public data: any, - ) { } + ) {} toJSON() { return { @@ -30,7 +30,7 @@ class Person2 { } } -const customSupportedDataTypes: { name: string, value: unknown, expected: unknown }[] = [ +const customSupportedDataTypes: { name: string; value: unknown; expected: unknown }[] = [ { name: 'person - 1', value: new Person('dinwwwh', new Date('2023-01-01')), @@ -38,13 +38,19 @@ const customSupportedDataTypes: { name: string, value: unknown, expected: unknow }, { name: 'person - 2', - value: new Person2('dinwwwh - 2', [{ nested: new Date('2023-01-02') }, new URL('https://orpc.dev')]), - expected: new Person2('dinwwwh - 2', [{ nested: new Date('2023-01-02') }, new URL('https://orpc.dev')]), + value: new Person2('dinwwwh - 2', [ + { nested: new Date('2023-01-02') }, + new URL('https://orpc.dev'), + ]), + expected: new Person2('dinwwwh - 2', [ + { nested: new Date('2023-01-02') }, + new URL('https://orpc.dev'), + ]), }, { name: 'should not resolve toJSON', value: { value: { toJSON: () => 'hello' } }, - expected: { value: { } }, + expected: { value: {} }, }, { name: 'should resolve invalid toJSON', @@ -63,75 +69,81 @@ function roundTripThroughWire(serializer: RPCJsonSerializer, value: unknown): un return serializer.deserializeJson({ ...wire, blobs }) } -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: any) => new Person(data.name, data.date), - }, - person2: { - condition: data => data instanceof Person2, - serialize: data => data.toJSON(), - deserialize: (data: any) => new Person2(data.name, data.data), +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: any) => new Person(data.name, data.date), + }, + person2: { + condition: (data) => data instanceof Person2, + serialize: (data) => data.toJSON(), + deserialize: (data: any) => new Person2(data.name, data.data), + }, }, - }, - }) + }) - function assert(value: unknown, expected: unknown) { - expect(roundTripThroughWire(serializer, value)).toEqual(expected) - } + function assert(value: unknown, expected: unknown) { + expect(roundTripThroughWire(serializer, value)).toEqual(expected) + } - it('flat', () => { - assert(value, expected) - }) + it('flat', () => { + assert(value, expected) + }) - it('nested object', () => { - assert({ - data: value, - nested: { - data: value, - }, - }, { - data: expected, - nested: { - data: expected, - }, + it('nested object', () => { + assert( + { + data: value, + nested: { + data: value, + }, + }, + { + data: expected, + nested: { + data: expected, + }, + }, + ) }) - }) - it('nested array', () => { - assert([value, [value]], [expected, [expected]]) - }) + it('nested array', () => { + assert([value, [value]], [expected, [expected]]) + }) - it('complex', () => { - assert({ - 'date': new Date('2023-01-01'), - 'url': new URL('https://dinwwwh.com'), - '!@#$%^^&()[]>?<~_<:"~+!_': value, - 'list': [value], - 'map': new Map([[value, value]]), - 'set': new Set([value]), - 'nested': { - nested: value, - }, - }, { - 'date': new Date('2023-01-01'), - 'url': new URL('https://dinwwwh.com'), - '!@#$%^^&()[]>?<~_<:"~+!_': expected, - 'list': [expected], - 'map': new Map([[expected, expected]]), - 'set': new Set([expected]), - 'nested': { - nested: expected, - }, + it('complex', () => { + assert( + { + 'date': new Date('2023-01-01'), + 'url': new URL('https://dinwwwh.com'), + '!@#$%^^&()[]>?<~_<:"~+!_': value, + 'list': [value], + 'map': new Map([[value, value]]), + 'set': new Set([value]), + 'nested': { + nested: value, + }, + }, + { + 'date': new Date('2023-01-01'), + 'url': new URL('https://dinwwwh.com'), + '!@#$%^^&()[]>?<~_<:"~+!_': expected, + 'list': [expected], + 'map': new Map([[expected, expected]]), + 'set': new Set([expected]), + 'nested': { + nested: expected, + }, + }, + ) }) - }) -}) + }, +) describe('rpcJsonSerializer: wire format', () => { const serializer = new RPCJsonSerializer() @@ -159,16 +171,18 @@ describe('rpcJsonSerializer: wire format', () => { min: '-Infinity', }) - expect(meta).toEqual(expect.arrayContaining([ - ['bigint', 'id'], - ['date', 'createdAt'], - ['set', 'tags'], - ['map', 'scores'], - ['url', 'homepage'], - ['nan', 'missing'], - ['infinity', 'max'], - ['infinity', 'min'], - ])) + expect(meta).toEqual( + expect.arrayContaining([ + ['bigint', 'id'], + ['date', 'createdAt'], + ['set', 'tags'], + ['map', 'scores'], + ['url', 'homepage'], + ['nan', 'missing'], + ['infinity', 'max'], + ['infinity', 'min'], + ]), + ) expect(meta).toHaveLength(8) }) @@ -192,12 +206,20 @@ describe('rpcJsonSerializer: edge cases', () => { const serializer = new RPCJsonSerializer() it('round-trips undefined inside arrays', () => { - expect(roundTripThroughWire(serializer, [undefined, 1, undefined])).toEqual([undefined, 1, undefined]) + expect(roundTripThroughWire(serializer, [undefined, 1, undefined])).toEqual([ + undefined, + 1, + undefined, + ]) }) it('round-trips undefined inside sets and maps', () => { - expect(roundTripThroughWire(serializer, new Set([undefined, 1]))).toEqual(new Set([undefined, 1])) - expect(roundTripThroughWire(serializer, new Map([[undefined, undefined]]))).toEqual(new Map([[undefined, undefined]])) + expect(roundTripThroughWire(serializer, new Set([undefined, 1]))).toEqual( + new Set([undefined, 1]), + ) + expect(roundTripThroughWire(serializer, new Map([[undefined, undefined]]))).toEqual( + new Map([[undefined, undefined]]), + ) }) it('omits undefined object properties by default', () => { @@ -254,7 +276,7 @@ describe('rpcJsonSerializer: custom handlers', () => { const serializer = new RPCJsonSerializer({ handlers: { date: { - condition: data => data instanceof Date, + condition: (data) => data instanceof Date, serialize: (value: Date) => `___TEST___${value.getTime()}`, deserialize: (value: string) => new Date(Number(value.slice(10))), }, @@ -286,7 +308,7 @@ describe('rpcJsonSerializer: custom handlers', () => { const serializer = new RPCJsonSerializer({ handlers: { person: { - condition: data => data instanceof Person, + condition: (data) => data instanceof Person, serialize: (data: Person) => data.toJSON(), deserialize: (data: any) => new Person(data.name, data.date), }, @@ -305,9 +327,9 @@ describe('rpcJsonSerializer: custom handlers', () => { const serializer = new RPCJsonSerializer({ handlers: { person: { - condition: data => data instanceof Person, + condition: (data) => data instanceof Person, serialize: (data: Person) => data.toJSON(), - deserialize: data => data, + deserialize: (data) => data, isTerminal: true, }, }, @@ -326,7 +348,7 @@ describe('rpcJsonSerializer: custom handlers', () => { const serializer = new RPCJsonSerializer({ handlers: { undefined: { - condition: data => data === undefined, + condition: (data) => data === undefined, serialize: () => null, deserialize: () => undefined, isTerminal: true, @@ -359,7 +381,7 @@ describe('rpcJsonSerializer: custom handlers', () => { const serializer = new RPCJsonSerializer({ handlers: { blobContainer: { - condition: data => data instanceof BlobContainer, + condition: (data) => data instanceof BlobContainer, serialize: (data: BlobContainer) => data.blob, deserialize: (blob: Blob) => new BlobContainer(blob), isTerminal: true, @@ -388,25 +410,25 @@ describe.each([ handlers: { ...extraHandlers, negZero: { - condition: data => Object.is(data, -0), + condition: (data) => Object.is(data, -0), serialize: () => '-0', deserialize: () => -0, isTerminal: true, }, secret: { - condition: data => typeof data === 'string' && data.startsWith('secret:'), + condition: (data) => typeof data === 'string' && data.startsWith('secret:'), serialize: (data: string) => data.slice(7), deserialize: (data: string) => `secret:${data}`, isTerminal: true, }, flag: { - condition: data => typeof data === 'boolean', + condition: (data) => typeof data === 'boolean', serialize: (data: boolean) => (data ? 1 : 0), deserialize: (data: number) => data === 1, isTerminal: true, }, nil: { - condition: data => data === null, + condition: (data) => data === null, serialize: () => 'null', deserialize: () => null, isTerminal: true, @@ -443,7 +465,7 @@ describe.each([ handlers: { ...extraHandlers, number: { - condition: data => typeof data === 'number', + condition: (data) => typeof data === 'number', serialize: (data: number) => `n${data}`, deserialize: (data: string) => Number(data.slice(1)), isTerminal: true, @@ -454,7 +476,11 @@ describe.each([ const serialized = numbers.serializeJson([1, Number.NaN, Number.POSITIVE_INFINITY]) expect(serialized.json).toEqual(['n1', null, 'Infinity']) - expect(serialized.meta).toEqual([['number', 0], ['nan', 1], ['infinity', 2]]) + expect(serialized.meta).toEqual([ + ['number', 0], + ['nan', 1], + ['infinity', 2], + ]) }) }) @@ -462,17 +488,38 @@ describe('rpcJsonSerializer: unknown types', () => { const serializer = new RPCJsonSerializer({ handlers: { url: undefined } }) it('leaves their values in serialized form, still restoring known values inside', () => { - expect(serializer.deserializeJson({ - json: { person: { name: 'dinwwwh', date: '2023-01-01T00:00:00.000Z' }, url: 'https://orpc.dev/' }, - meta: [['date', 'person', 'date'], ['person', 'person'], ['url', 'url']], - })).toEqual({ person: { name: 'dinwwwh', date: new Date('2023-01-01') }, url: 'https://orpc.dev/' }) + expect( + serializer.deserializeJson({ + json: { + person: { name: 'dinwwwh', date: '2023-01-01T00:00:00.000Z' }, + url: 'https://orpc.dev/', + }, + meta: [ + ['date', 'person', 'date'], + ['person', 'person'], + ['url', 'url'], + ], + }), + ).toEqual({ + person: { name: 'dinwwwh', date: new Date('2023-01-01') }, + url: 'https://orpc.dev/', + }) }) it('skips their entries without walking the path', () => { - expect(serializer.deserializeJson({ - json: { a: { b: { x: '1', y: '2', z: '3' } }, v: '4' }, - meta: [['bigint', 'a', 'b', 'x'], ['unknown', 'a', 'b', 'y'], ['bigint', 'a', 'b', 'z'], ['unknown', 'missing', 'deep'], ['bigint', 'v'], ['unknown', 'v', 'x']], - })).toEqual({ a: { b: { x: 1n, y: '2', z: 3n } }, v: 4n }) + expect( + serializer.deserializeJson({ + json: { a: { b: { x: '1', y: '2', z: '3' } }, v: '4' }, + meta: [ + ['bigint', 'a', 'b', 'x'], + ['unknown', 'a', 'b', 'y'], + ['bigint', 'a', 'b', 'z'], + ['unknown', 'missing', 'deep'], + ['bigint', 'v'], + ['unknown', 'v', 'x'], + ], + }), + ).toEqual({ a: { b: { x: 1n, y: '2', z: 3n } }, v: 4n }) }) }) @@ -480,7 +527,15 @@ describe('rpcJsonSerializer: deserialize', () => { const serializer = new RPCJsonSerializer() it.each([ - ['json', { count: 10n, list: [{ tags: new Set(['a']), nested: [new Date('2023-01-01'), new URL('https://orpc.dev')] }] }], + [ + 'json', + { + count: 10n, + list: [ + { tags: new Set(['a']), nested: [new Date('2023-01-01'), new URL('https://orpc.dev')] }, + ], + }, + ], ['blobs', { files: [new Blob(['hello'])], map: new Map([[1n, new Set(['a'])]]) }], ])('does not mutate the input and can deserialize repeatedly: %s', (_, value) => { // https://github.com/middleapi/orpc/issues/2053 @@ -495,11 +550,21 @@ describe('rpcJsonSerializer: deserialize', () => { it('restores entries that share, leave and return to a path prefix', () => { const payload: RPCJsonSerialization = { json: { a: { b: { x: '1', y: '2' }, z: '3' }, c: ['4', { w: '5', v: '6' }] }, - meta: [['bigint', 'a', 'b', 'x'], ['bigint', 'a', 'z'], ['bigint', 'a', 'b', 'y'], ['bigint', 'c', 1, 'w'], ['bigint', 'c', '1', 'v'], ['bigint', 'c', 0]], + meta: [ + ['bigint', 'a', 'b', 'x'], + ['bigint', 'a', 'z'], + ['bigint', 'a', 'b', 'y'], + ['bigint', 'c', 1, 'w'], + ['bigint', 'c', '1', 'v'], + ['bigint', 'c', 0], + ], } const snapshot = JSON.stringify(payload) - expect(serializer.deserializeJson(payload)).toEqual({ a: { b: { x: 1n, y: 2n }, z: 3n }, c: [4n, { w: 5n, v: 6n }] }) + expect(serializer.deserializeJson(payload)).toEqual({ + a: { b: { x: 1n, y: 2n }, z: 3n }, + c: [4n, { w: 5n, v: 6n }], + }) expect(JSON.stringify(payload)).toBe(snapshot) }) }) @@ -512,45 +577,66 @@ describe('rpcJsonSerializer: security', () => { expect((Object.prototype as any).polluted).toBeUndefined() }) - it.each(['doesNotExist', '__proto__', 'constructor', 'prototype'])('throws when a meta or blob path references the non-own segment "%s"', (segment) => { - const error = `Invalid RPC serialized data: segment "${segment}" does not exist.` - - expect( - () => serializer.deserializeJson({ json: { o: {} }, meta: [['date', segment]] }), - ).toThrow(error) - - expect( - () => serializer.deserializeJson({ json: { o: {} }, meta: [['date', 'o', segment]] }), - ).toThrow(error) - - expect( - () => serializer.deserializeJson({ json: { o: {} }, meta: [['date', segment, 'o']] }), - ).toThrow(error) - - expect( - () => serializer.deserializeJson({ json: { o: {} }, blobs: [new Blob()], maps: [[segment]] }), - ).toThrow(error) - - expect( - () => serializer.deserializeJson({ json: { o: {} }, blobs: [new Blob()], maps: [['o', segment]] }), - ).toThrow(error) - - expect( - () => serializer.deserializeJson({ json: { o: {} }, blobs: [new Blob()], maps: [[segment, 'o']] }), - ).toThrow(error) - }) + it.each(['doesNotExist', '__proto__', 'constructor', 'prototype'])( + 'throws when a meta or blob path references the non-own segment "%s"', + (segment) => { + const error = `Invalid RPC serialized data: segment "${segment}" does not exist.` + + expect(() => + serializer.deserializeJson({ json: { o: {} }, meta: [['date', segment]] }), + ).toThrow(error) + + expect(() => + serializer.deserializeJson({ json: { o: {} }, meta: [['date', 'o', segment]] }), + ).toThrow(error) + + expect(() => + serializer.deserializeJson({ json: { o: {} }, meta: [['date', segment, 'o']] }), + ).toThrow(error) + + expect(() => + serializer.deserializeJson({ json: { o: {} }, blobs: [new Blob()], maps: [[segment]] }), + ).toThrow(error) + + expect(() => + serializer.deserializeJson({ + json: { o: {} }, + blobs: [new Blob()], + maps: [['o', segment]], + }), + ).toThrow(error) + + expect(() => + serializer.deserializeJson({ + json: { o: {} }, + blobs: [new Blob()], + maps: [[segment, 'o']], + }), + ).toThrow(error) + }, + ) it('throws when a blobs entry is not a Blob', () => { for (const value of [4294967295, 'text', null, {}, []]) { - expect(() => serializer.deserializeJson({ json: [null], blobs: [value as any], maps: [[0]] })) - .toThrow('Invalid RPC serialized data: blob 0 is not a Blob.') + expect(() => + serializer.deserializeJson({ json: [null], blobs: [value as any], maps: [[0]] }), + ).toThrow('Invalid RPC serialized data: blob 0 is not a Blob.') } - expect(() => serializer.deserializeJson({ json: [null, null], blobs: [new Blob()], maps: [[0], [1]] })) - .toThrow('Invalid RPC serialized data: blob 1 is not a Blob.') + expect(() => + serializer.deserializeJson({ json: [null, null], blobs: [new Blob()], maps: [[0], [1]] }), + ).toThrow('Invalid RPC serialized data: blob 1 is not a Blob.') }) - it.each(['nonexistent', '__proto__', 'constructor', 'prototype', 'toString', 'hasOwnProperty', 'valueOf'])('never resolves the meta type "%s" through the prototype chain', (type) => { + it.each([ + 'nonexistent', + '__proto__', + 'constructor', + 'prototype', + 'toString', + 'hasOwnProperty', + 'valueOf', + ])('never resolves the meta type "%s" through the prototype chain', (type) => { expect(serializer.deserializeJson({ json: 1, meta: [[type]] })).toBe(1) }) @@ -569,26 +655,36 @@ describe('rpcJsonSerializer: security', () => { ['map', 'an array', [null, 'text', 1, true, {}]], ])('%s rejects mistyped serialized values', (type, expected, rejected) => { for (const value of rejected) { - expect(() => serializer.deserializeJson({ json: { value }, meta: [[type, 'value']] })) - .toThrow(`Invalid RPC serialized data: type "${type}" expects ${expected}.`) + expect(() => + serializer.deserializeJson({ json: { value }, meta: [[type, 'value']] }), + ).toThrow(`Invalid RPC serialized data: type "${type}" expects ${expected}.`) } }) it('bigint rejects non-canonical strings and more than 4300 digits', () => { - expect(serializer.deserializeJson({ json: '9'.repeat(4300), meta: [['bigint']] })).toBe(BigInt('9'.repeat(4300))) + expect(serializer.deserializeJson({ json: '9'.repeat(4300), meta: [['bigint']] })).toBe( + BigInt('9'.repeat(4300)), + ) for (const bad of ['9'.repeat(4301), '0x10', ' 1 ']) { - expect(() => serializer.deserializeJson({ json: bad, meta: [['bigint']] })) - .toThrow('Invalid RPC serialized data: type "bigint" expects a decimal integer string of at most 4300 digits.') + expect(() => serializer.deserializeJson({ json: bad, meta: [['bigint']] })).toThrow( + 'Invalid RPC serialized data: type "bigint" expects a decimal integer string of at most 4300 digits.', + ) } }) it('rejects a value already restored by an earlier meta entry', () => { - expect(() => serializer.deserializeJson({ json: { value: '1' }, meta: [['bigint', 'value'], ['url', 'value']] })) - .toThrow('Invalid RPC serialized data: type "url" expects a string.') + expect(() => + serializer.deserializeJson({ + json: { value: '1' }, + meta: [ + ['bigint', 'value'], + ['url', 'value'], + ], + }), + ).toThrow('Invalid RPC serialized data: type "url" expects a string.') }) - /* eslint-disable no-proto, no-restricted-properties */ it('serializes own __proto__ keys as plain data', () => { // JSON.parse creates own __proto__ properties, exactly like a real request body const input = JSON.parse('{"__proto__": {"polluted": true}}') @@ -624,7 +720,9 @@ describe('rpcJsonSerializer: security', () => { const blob = new Blob(['x']) const restored = serializer.deserializeJson({ - json: JSON.parse('{"constructor": {"prototype": {"when": "2023-01-01T00:00:00.000Z", "polluted": null}}}'), + json: JSON.parse( + '{"constructor": {"prototype": {"when": "2023-01-01T00:00:00.000Z", "polluted": null}}}', + ), meta: [['date', 'constructor', 'prototype', 'when']], maps: [['constructor', 'prototype', 'polluted']], blobs: [blob], @@ -647,18 +745,20 @@ describe('rpcJsonSerializer: security', () => { expect(restored.__proto__).toBe(blob) expect(Object.getPrototypeOf(restored)).toBe(Object.prototype) }) - /* eslint-enable no-proto, no-restricted-properties */ }) describe('rpcJsonSerializer: meta cannot descend into restored values', () => { class Human { - constructor(public name: string, public age: number) {} + constructor( + public name: string, + public age: number, + ) {} } const serializer = new RPCJsonSerializer({ handlers: { human: { - condition: v => v instanceof Human, + condition: (v) => v instanceof Human, serialize: (v: Human) => ({ name: v.name, age: v.age }), deserialize: (v: any) => { if (typeof v?.name !== 'string' || typeof v?.age !== 'number') @@ -666,104 +766,205 @@ describe('rpcJsonSerializer: meta cannot descend into restored values', () => { return new Human(v.name, v.age) }, }, - identity: { // returns the very object it validated + identity: { + // returns the very object it validated condition: () => false, - serialize: v => v, + serialize: (v) => v, deserialize: (v: any) => { - if (typeof v?.name !== 'string') - throw new TypeError('invalid') + if (typeof v?.name !== 'string') throw new TypeError('invalid') return v }, }, - list: { // returns the very array it validated + list: { + // returns the very array it validated condition: () => false, - serialize: v => v, + serialize: (v) => v, deserialize: (v: any) => { - if (!Array.isArray(v) || !v.every(item => typeof item === 'string')) + if (!Array.isArray(v) || !v.every((item) => typeof item === 'string')) throw new TypeError('invalid list') return v }, }, - bytes: { // returns the very non-JSON value it validated + bytes: { + // returns the very non-JSON value it validated condition: () => false, - serialize: v => v, + serialize: (v) => v, deserialize: (v: any) => { - if (!(v instanceof Uint8Array) || v.some(b => b > 9)) + if (!(v instanceof Uint8Array) || v.some((b) => b > 9)) throw new TypeError('invalid bytes') return v }, }, double: { condition: () => false, - serialize: v => v, + serialize: (v) => v, deserialize: (v: any) => v * 2 + 100, }, zero: { condition: () => false, - serialize: v => v, + serialize: (v) => v, deserialize: () => 0, }, }, }) it('rejects a meta path that descends into a custom handler result', () => { - expect(() => serializer.deserializeJson({ json: { p: { name: 'http://evil/', age: 1 } }, meta: [['human', 'p'], ['url', 'p', 'name']] })) - .toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') - expect(() => serializer.deserializeJson({ json: { p: { name: '123', age: 1 } }, meta: [['human', 'p'], ['bigint', 'p', 'name']] })) - .toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { p: { name: 'http://evil/', age: 1 } }, + meta: [ + ['human', 'p'], + ['url', 'p', 'name'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { p: { name: '123', age: 1 } }, + meta: [ + ['human', 'p'], + ['bigint', 'p', 'name'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') }) it('rejects descent into a root-level custom handler result', () => { - expect(() => serializer.deserializeJson({ json: { name: '123', age: 1 }, meta: [['human'], ['bigint', 'name']] })) - .toThrow('Invalid RPC serialized data: segment "json" cannot be walked into.') - expect(() => serializer.deserializeJson({ json: { x: '1', name: '2' }, meta: [['bigint', 'x'], ['identity'], ['bigint', 'name']] })) - .toThrow('Invalid RPC serialized data: segment "json" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { name: '123', age: 1 }, + meta: [['human'], ['bigint', 'name']], + }), + ).toThrow('Invalid RPC serialized data: segment "json" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { x: '1', name: '2' }, + meta: [['bigint', 'x'], ['identity'], ['bigint', 'name']], + }), + ).toThrow('Invalid RPC serialized data: segment "json" cannot be walked into.') }) it('rejects descent when entries share a path prefix with earlier ones', () => { const json = { p: { name: 'http://evil/', age: 1, at: null }, q: '2020-01-01T00:00:00.000Z' } - expect(() => serializer.deserializeJson({ json, meta: [['date', 'p', 'at'], ['human', 'p'], ['url', 'p', 'name']] })) - .toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') - expect(() => serializer.deserializeJson({ json, meta: [['human', 'p'], ['date', 'q'], ['url', 'p', 'name']] })) - .toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') - expect(() => serializer.deserializeJson({ json: { a: { b: { name: '1', c: null } } }, meta: [['date', 'a', 'b', 'c'], ['identity', 'a', 'b'], ['bigint', 'a', 'b', 'name']] })) - .toThrow('Invalid RPC serialized data: segment "b" cannot be walked into.') - expect(() => serializer.deserializeJson({ json: { a: { name: '1', b: { x: '2', y: '3' } } }, meta: [['bigint', 'a', 'b', 'x'], ['identity', 'a'], ['bigint', 'a', 'b', 'y']] })) - .toThrow('Invalid RPC serialized data: segment "a" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json, + meta: [ + ['date', 'p', 'at'], + ['human', 'p'], + ['url', 'p', 'name'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json, + meta: [ + ['human', 'p'], + ['date', 'q'], + ['url', 'p', 'name'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { a: { b: { name: '1', c: null } } }, + meta: [ + ['date', 'a', 'b', 'c'], + ['identity', 'a', 'b'], + ['bigint', 'a', 'b', 'name'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "b" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { a: { name: '1', b: { x: '2', y: '3' } } }, + meta: [ + ['bigint', 'a', 'b', 'x'], + ['identity', 'a'], + ['bigint', 'a', 'b', 'y'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "a" cannot be walked into.') }) it('checks that a segment still exists after an earlier entry shrinks its array', () => { - expect(() => serializer.deserializeJson({ json: { a: [{ x: '1' }, { y: '2' }] }, meta: [['bigint', 'a', 1, 'y'], ['zero', 'a', 'length'], ['bigint', 'a', 1, 'y']] })) - .toThrow('Invalid RPC serialized data: segment "1" does not exist.') + expect(() => + serializer.deserializeJson({ + json: { a: [{ x: '1' }, { y: '2' }] }, + meta: [ + ['bigint', 'a', 1, 'y'], + ['zero', 'a', 'length'], + ['bigint', 'a', 1, 'y'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "1" does not exist.') }) it('treats numeric and string segments as the same position', () => { - expect(() => serializer.deserializeJson({ json: [{ name: '123', age: 1 }], meta: [['human', 0], ['bigint', '0', 'name']] })) - .toThrow('Invalid RPC serialized data: segment "0" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: [{ name: '123', age: 1 }], + meta: [ + ['human', 0], + ['bigint', '0', 'name'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "0" cannot be walked into.') }) it('rejects descent even when the handler returned the validated object itself', () => { - expect(() => serializer.deserializeJson({ json: { p: { name: '1' } }, meta: [['identity', 'p'], ['bigint', 'p', 'name']] })) - .toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') - expect(() => serializer.deserializeJson({ json: { l: ['1'] }, meta: [['list', 'l'], ['bigint', 'l', 0]] })) - .toThrow('Invalid RPC serialized data: segment "l" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { p: { name: '1' } }, + meta: [ + ['identity', 'p'], + ['bigint', 'p', 'name'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "p" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { l: ['1'] }, + meta: [ + ['list', 'l'], + ['bigint', 'l', 0], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "l" cannot be walked into.') }) it('rejects descent into a non-JSON value the handler returned as is', () => { // structured clone transports (e.g. MessagePort with experimental_transfer) can deliver non-JSON values const bytes = new Uint8Array([1]) - expect(() => serializer.deserializeJson({ json: { b: bytes }, meta: [['bytes', 'b'], ['double', 'b', 0]] })) - .toThrow('Invalid RPC serialized data: segment "b" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { b: bytes }, + meta: [ + ['bytes', 'b'], + ['double', 'b', 0], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "b" cannot be walked into.') // structured clone also keeps shared references, so the same value can sit at another path - expect(() => serializer.deserializeJson({ json: { b: bytes, c: bytes }, meta: [['bytes', 'b'], ['double', 'c', 0]] })) - .toThrow('Invalid RPC serialized data: segment "c" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { b: bytes, c: bytes }, + meta: [ + ['bytes', 'b'], + ['double', 'c', 0], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "c" cannot be walked into.') expect(bytes[0]).toBe(1) }) it('does not let a payload mutate a shared instance returned by a handler', () => { - const CURRENCIES: Record = { USD: { code: 'USD', symbol: '$' } } + const CURRENCIES: Record = { + USD: { code: 'USD', symbol: '$' }, + } const s = new RPCJsonSerializer({ handlers: { currency: { @@ -778,30 +979,65 @@ describe('rpcJsonSerializer: meta cannot descend into restored values', () => { }, }, }) - expect(() => s.deserializeJson({ json: { c: 'USD' }, meta: [['currency', 'c'], ['date', 'c', 'symbol']] })) - .toThrow('Invalid RPC serialized data: segment "c" cannot be walked into.') + expect(() => + s.deserializeJson({ + json: { c: 'USD' }, + meta: [ + ['currency', 'c'], + ['date', 'c', 'symbol'], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "c" cannot be walked into.') expect(CURRENCIES.USD!.symbol).toBe('$') }) it('rejects descent into built-in results and blobs', () => { - expect(() => serializer.deserializeJson({ json: { a: ['2020-01-01T00:00:00.000Z'] }, meta: [['set', 'a'], ['date', 'a', 0]] })) - .toThrow('Invalid RPC serialized data: segment "a" cannot be walked into.') - expect(() => serializer.deserializeJson({ json: { a: {} }, blobs: [new Blob()], maps: [['a']], meta: [['date', 'a', 'x']] })) - .toThrow('Invalid RPC serialized data: segment "a" cannot be walked into.') - expect(() => serializer.deserializeJson({ json: { a: { x: null } }, blobs: [new Blob(), new Blob()], maps: [['a'], ['a', 'x']] })) - .toThrow('Invalid RPC serialized data: segment "a" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { a: ['2020-01-01T00:00:00.000Z'] }, + meta: [ + ['set', 'a'], + ['date', 'a', 0], + ], + }), + ).toThrow('Invalid RPC serialized data: segment "a" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { a: {} }, + blobs: [new Blob()], + maps: [['a']], + meta: [['date', 'a', 'x']], + }), + ).toThrow('Invalid RPC serialized data: segment "a" cannot be walked into.') + expect(() => + serializer.deserializeJson({ + json: { a: { x: null } }, + blobs: [new Blob(), new Blob()], + maps: [['a'], ['a', 'x']], + }), + ).toThrow('Invalid RPC serialized data: segment "a" cannot be walked into.') }) it('still stacks handlers on the same position, governed by their own type checks', () => { - expect(serializer.deserializeJson({ json: { p: { name: 'x', age: 1 } }, meta: [['identity', 'p'], ['human', 'p']] })) - .toEqual({ p: new Human('x', 1) }) + expect( + serializer.deserializeJson({ + json: { p: { name: 'x', age: 1 } }, + meta: [ + ['identity', 'p'], + ['human', 'p'], + ], + }), + ).toEqual({ p: new Human('x', 1) }) }) it('still round-trips nested custom and built-in values (children-first order)', () => { const value = { p: new Human('x', 1), s: new Set([new Date(0), new Human('y', 2)]), - m: new Map([[1n, new Set([undefined])], ['k', [new Human('z', 3)]]]), + m: new Map([ + [1n, new Set([undefined])], + ['k', [new Human('z', 3)]], + ]), } expect(serializer.deserializeJson(serializer.serializeJson(value))).toEqual(value) expect(roundTripThroughWire(serializer, value)).toEqual(value) @@ -812,25 +1048,27 @@ describe('rpcJsonSerializer: meta cannot descend into restored values', () => { const s = new RPCJsonSerializer({ handlers: { file: { - condition: v => v instanceof Blob, - serialize: v => v, + condition: (v) => v instanceof Blob, + serialize: (v) => v, isTerminal: true, deserialize: (v: any) => { - if (!(v instanceof Blob)) - throw new TypeError('invalid blob') + if (!(v instanceof Blob)) throw new TypeError('invalid blob') return v }, }, box: { - condition: v => v instanceof Human, + condition: (v) => v instanceof Human, serialize: (v: Human) => ({ name: v.name, age: v.age, attachments: new Set([blob]) }), deserialize: (v: any) => new Human(v.name, v.attachments.size), }, }, }) - expect(s.deserializeJson(s.serializeJson({ root: blob, nested: new Set([blob]), p: new Human('x', 0) }))) - .toEqual({ root: blob, nested: new Set([blob]), p: new Human('x', 1) }) + expect( + s.deserializeJson( + s.serializeJson({ root: blob, nested: new Set([blob]), p: new Human('x', 0) }), + ), + ).toEqual({ root: blob, nested: new Set([blob]), p: new Human('x', 1) }) expect(s.deserializeJson(s.serializeJson(blob))).toBe(blob) }) }) diff --git a/packages/client/src/rpc-json-serializer.ts b/packages/client/src/rpc-json-serializer.ts index 5b54319527..e6f4ef5e43 100644 --- a/packages/client/src/rpc-json-serializer.ts +++ b/packages/client/src/rpc-json-serializer.ts @@ -1,10 +1,26 @@ import type { Segment } from '@orpc/shared' -import { copyOnWrite, DEFAULT_MAX_BIGINT_DIGITS, isPlainObject, NullProtoObj, parseBigIntString } from '@orpc/shared' +import { + copyOnWrite, + DEFAULT_MAX_BIGINT_DIGITS, + isPlainObject, + NullProtoObj, + parseBigIntString, +} 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 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 @@ -74,7 +90,9 @@ const DEFAULT_RPC_JSON_SERIALIZER_HANDLERS: Record 0 ? 'Infinity' : '-Infinity' }, deserialize(serialized: string): number { - assertSerializedType(serialized === 'Infinity' || serialized === '-Infinity', 'infinity', '"Infinity" or "-Infinity"') + assertSerializedType( + serialized === 'Infinity' || serialized === '-Infinity', + 'infinity', + '"Infinity" or "-Infinity"', + ) return serialized === 'Infinity' ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY }, isTerminal: true, @@ -287,7 +313,13 @@ export class RPCJsonSerializer { * `segments` is a shared mutable stack (push/pop while walking), * so it must be copied before being stored in `meta` or `maps`. */ - private serializeValue(data: unknown, segments: Segment[], meta: RPCJsonSerializationMeta[], maps: Segment[][], blobs: Blob[]): unknown { + private serializeValue( + data: unknown, + segments: Segment[], + meta: RPCJsonSerializationMeta[], + maps: Segment[][], + blobs: Blob[], + ): unknown { const handlerEntries = this.handlerEntries /** @@ -344,7 +376,13 @@ export class RPCJsonSerializer { return result } if (data instanceof Map) { - const result = this.serializeValue(Array.from(data.entries()), segments, meta, maps, blobs) + const result = this.serializeValue( + Array.from(data.entries()), + segments, + meta, + maps, + blobs, + ) meta.push(['map', ...segments]) return result } @@ -525,7 +563,7 @@ export class RPCJsonSerializer { currentRef[preSegment] = value if (isWalkable(value)) { - (restored ??= new Set()).add(value) + ;(restored ??= new Set()).add(value) } } } diff --git a/packages/client/src/rpc-serializer.test.ts b/packages/client/src/rpc-serializer.test.ts index fbae71df2a..34dd7b5a92 100644 --- a/packages/client/src/rpc-serializer.test.ts +++ b/packages/client/src/rpc-serializer.test.ts @@ -1,8 +1,9 @@ import { isAsyncIteratorObject, parseEmptyableJSON } from '@orpc/shared' import { ErrorEvent, getEventMeta, withEventMeta } from '@standard-server/core' -import { builtInRPCSupportDataTypes } from '../../../tests/rpc/__shared__/built-in-support-data-types' -import { ORPCError } from './error' -import { RPCSerializer } from './rpc-serializer' + +import { builtInRPCSupportDataTypes } from '../../../tests/rpc/__shared__/built-in-support-data-types.ts' +import { ORPCError } from './error.ts' +import { RPCSerializer } from './rpc-serializer.ts' describe('rpcSerializer', () => { describe.each(builtInRPCSupportDataTypes)('$name', ({ value, expected }) => { @@ -19,11 +20,7 @@ describe('rpcSerializer', () => { } it('should work on flat', async () => { - expect( - serializeAndDeserialize(value), - ).toEqual( - expected, - ) + expect(serializeAndDeserialize(value)).toEqual(expected) }) it('should work on nested object', async () => { @@ -31,11 +28,9 @@ describe('rpcSerializer', () => { serializeAndDeserialize({ data: value, }), - ).toEqual( - { - data: expected, - }, - ) + ).toEqual({ + data: expected, + }) }) it('should work on complex object', async () => { @@ -183,7 +178,9 @@ describe('rpcSerializer', () => { }) it('on error with ORPCError', async () => { - const error = withEventMeta(new ORPCError('BAD_GATEWAY', { data: { order: 3 } }), { id: '123456' }) + const error = withEventMeta(new ORPCError('BAD_GATEWAY', { data: { order: 3 } }), { + id: '123456', + }) const iterator = (async function* () { yield 1 @@ -251,9 +248,11 @@ describe('rpcSerializer', () => { }) it('deserialize an invalid ORPCError json', async () => { - const iterator = serializer.deserialize((async function* () { - throw new ErrorEvent({ json: { value: 1234 } }) - })()) as any + 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) @@ -332,31 +331,39 @@ describe('rpcSerializer', () => { }) it('rejects meta paths escaping the payload in plain bodies', () => { - const body = JSON.parse(JSON.stringify({ - json: { o: {} }, - meta: [['date', '__proto__', 'polluted']], - })) + const body = JSON.parse( + JSON.stringify({ + json: { o: {} }, + meta: [['date', '__proto__', 'polluted']], + }), + ) expect(() => serializer.deserialize(body)).toThrow('Invalid RPC serialized data') }) it('rejects meta paths escaping the payload in form data bodies', () => { const form = new FormData() - form.set('data', JSON.stringify({ - json: { o: {} }, - meta: [['date', '__proto__', 'polluted']], - maps: [], - })) + form.set( + 'data', + JSON.stringify({ + json: { o: {} }, + meta: [['date', '__proto__', 'polluted']], + maps: [], + }), + ) expect(() => serializer.deserialize(form)).toThrow('Invalid RPC serialized data') }) it('rejects blob maps escaping the payload in form data bodies', () => { const form = new FormData() - form.set('data', JSON.stringify({ - json: { o: {} }, - maps: [['__proto__', 'polluted']], - })) + form.set( + 'data', + JSON.stringify({ + json: { o: {} }, + maps: [['__proto__', 'polluted']], + }), + ) form.set('0', new Blob(['x'])) expect(() => serializer.deserialize(form)).toThrow('Invalid RPC serialized data') @@ -364,18 +371,25 @@ describe('rpcSerializer', () => { it('rejects blobs smuggled in plain bodies', () => { // would otherwise resize the array to 2 ** 32 - 1 empty slots for `set` to iterate - const body = JSON.parse('{"json":[1],"meta":[["set"]],"maps":[["length"]],"blobs":[4294967295]}') + const body = JSON.parse( + '{"json":[1],"meta":[["set"]],"maps":[["length"]],"blobs":[4294967295]}', + ) - expect(() => serializer.deserialize(body)).toThrow('Invalid RPC serialized data: blob 0 is not a Blob.') + expect(() => serializer.deserialize(body)).toThrow( + 'Invalid RPC serialized data: blob 0 is not a Blob.', + ) }) it('cannot resize an array through blob maps in form data bodies', () => { const form = new FormData() - form.set('data', JSON.stringify({ - json: [1], - meta: [['set']], - maps: [['length']], - })) + form.set( + 'data', + JSON.stringify({ + json: [1], + meta: [['set']], + maps: [['length']], + }), + ) form.set('0', new Blob(['x'])) // only a Blob can land there, and the spec rejects it as an array length diff --git a/packages/client/src/rpc-serializer.ts b/packages/client/src/rpc-serializer.ts index dbc5af33d5..61aad786e0 100644 --- a/packages/client/src/rpc-serializer.ts +++ b/packages/client/src/rpc-serializer.ts @@ -1,10 +1,11 @@ -import type { StandardBody } from '@standard-server/core' -import type { RPCJsonSerializerOptions } from './rpc-json-serializer' import { isAsyncIteratorObject, stringifyJSON } from '@orpc/shared' +import type { StandardBody } from '@standard-server/core' import { ErrorEvent } from '@standard-server/core' -import { wrapAsyncIteratorPreservingEventMeta } from './async-iterator-object' -import { createORPCErrorFromJson, isORPCErrorJson, toORPCError } from './error-utils' -import { RPCJsonSerializer } from './rpc-json-serializer' + +import { wrapAsyncIteratorPreservingEventMeta } from './async-iterator-object.ts' +import { createORPCErrorFromJson, isORPCErrorJson, toORPCError } from './error-utils.ts' +import type { RPCJsonSerializerOptions } from './rpc-json-serializer.ts' +import { RPCJsonSerializer } from './rpc-json-serializer.ts' export interface RPCSerializerSerializeOptions { /** @@ -32,9 +33,7 @@ export interface RPCSerializerOptions extends RPCJsonSerializerOptions { export class RPCSerializer extends RPCJsonSerializer { private readonly defaultSerializeOptions: RPCSerializerOptions['serialize'] - constructor( - options: RPCSerializerOptions = {}, - ) { + constructor(options: RPCSerializerOptions = {}) { super(options) this.defaultSerializeOptions = options.serialize } @@ -55,14 +54,15 @@ export class RPCSerializer extends RPCJsonSerializer { return { done: result.done, value: this.serializePayload(result.value, false) } }, - mapError: e => new ErrorEvent( - this.serializePayload(toORPCError(e).toJSON(), false), - { cause: e }, - ), + mapError: (e) => + new ErrorEvent(this.serializePayload(toORPCError(e).toJSON(), false), { cause: e }), }) } - const useFormDataForBlobs = options.useFormDataForBlobFields ?? this.defaultSerializeOptions?.useFormDataForBlobFields ?? true + const useFormDataForBlobs = + options.useFormDataForBlobFields ?? + this.defaultSerializeOptions?.useFormDataForBlobFields ?? + true return this.serializePayload(data, useFormDataForBlobs) } diff --git a/packages/client/src/types.test-d.ts b/packages/client/src/types.test-d.ts index 64500d8798..af7b75464e 100644 --- a/packages/client/src/types.test-d.ts +++ b/packages/client/src/types.test-d.ts @@ -1,25 +1,44 @@ -import type { ORPCError } from './error' -import type { Client, ClientContext, InferClientBodyInputs, InferClientBodyOutputs, InferClientContext, InferClientError, InferClientErrors, InferClientInputs, InferClientOutputs } from './types' +import type { ORPCError } from './error.ts' +import type { + Client, + ClientContext, + InferClientBodyInputs, + InferClientBodyOutputs, + InferClientContext, + InferClientError, + InferClientErrors, + InferClientInputs, + InferClientOutputs, +} from './types.ts' describe('client', () => { - const client: Client<{ cache?: boolean }, string, number, Error | ORPCError<'OVERRIDE', unknown>> = async (...args) => { + const client: Client< + { cache?: boolean }, + string, + number, + Error | ORPCError<'OVERRIDE', unknown> + > = async (...args) => { const [input, options] = args expectTypeOf(input).toEqualTypeOf() - expectTypeOf(options).toExtend<{ context?: { cache?: boolean }, signal?: AbortSignal } | undefined>() + expectTypeOf(options).toExtend< + { context?: { cache?: boolean }; signal?: AbortSignal } | undefined + >() return 123 } it('just a function', () => { - expectTypeOf(client).toExtend<(input: string, options: { context?: ClientContext, signal?: AbortSignal }) => Promise>() + expectTypeOf(client).toExtend< + (input: string, options: { context?: ClientContext; signal?: AbortSignal }) => Promise + >() }) it('infer correct input', () => { expectTypeOf(client).parameter(0).toEqualTypeOf() // @ts-expect-error - input is required - client() + void client() }) it('optional undefinedable input', () => { @@ -27,44 +46,49 @@ describe('client', () => { expectTypeOf(client).parameter(0).toEqualTypeOf<{ val: string } | undefined>() - client(undefined) - client() + void client(undefined) + void client() }) it('accept signal', () => { - client('123', { signal: new AbortSignal() }) + void client('123', { signal: new AbortSignal() }) // @ts-expect-error - invalid signal - client('123', { signal: 1234 }) + void client('123', { signal: 1234 }) }) describe('context', () => { it('can accept context', () => { const client = {} as Client<{ userId: string }, { val: string }, { val: number }, Error> - client({ val: '123' }, { context: { userId: '123' } }) + void client({ val: '123' }, { context: { userId: '123' } }) // @ts-expect-error - invalid context - client({ val: '123' }, { context: { userId: 123 } }) + void client({ val: '123' }, { context: { userId: 123 } }) // @ts-expect-error - context is required - client({ val: '123' }) + void client({ val: '123' }) }) it('optional options when context is optional', () => { const client = {} as Client<{ userId?: string }, { val: string }, { val: number }, Error> - client({ val: '123' }) - client({ val: '123' }, { context: { userId: '123' } }) + void client({ val: '123' }) + void client({ val: '123' }, { context: { userId: '123' } }) }) it('can call without args when both input and context are optional', () => { - const client = {} as Client<{ userId?: string }, undefined | { val: string }, { val: number }, Error> - - client() - client({ val: 'string' }) - client({ val: 'string' }, { context: { userId: '123' } }) + const client = {} as Client< + { userId?: string }, + undefined | { val: string }, + { val: number }, + Error + > + + void client() + void client({ val: 'string' }) + void client({ val: 'string' }, { context: { userId: '123' } }) // @ts-expect-error - input is invalid - client({ val: 123 }, { context: { userId: '123' } }) + void client({ val: 123 }, { context: { userId: '123' } }) // @ts-expect-error - context is invalid - client({ val: '123' }, { context: { userId: 123 } }) + void client({ val: '123' }, { context: { userId: 123 } }) }) }) @@ -75,7 +99,9 @@ describe('client', () => { it('can reverse infer', () => { expectTypeOf< typeof client extends Client ? [C, I, O, E] : never - >().toEqualTypeOf<[{ cache?: boolean }, string, number, Error | ORPCError<'OVERRIDE', unknown>]>() + >().toEqualTypeOf< + [{ cache?: boolean }, string, number, Error | ORPCError<'OVERRIDE', unknown>] + >() }) }) @@ -149,6 +175,8 @@ describe('infer utilities', () => { }) it('InferClientError', () => { - expectTypeOf>().toEqualTypeOf | ORPCError<'NESTED_PING', unknown>>() + expectTypeOf>().toEqualTypeOf< + Error | ORPCError<'PING', unknown> | ORPCError<'NESTED_PING', unknown> + >() }) }) diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 28c9df9768..6140fb1e9c 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -10,9 +10,8 @@ export interface ClientOptions { context: T } -export type FriendlyClientOptions - = & Omit, 'context'> - & (object extends T ? { context?: T | undefined } : { context: T }) +export type FriendlyClientOptions = Omit, 'context'> & + (object extends T ? { context?: T | undefined } : { context: T }) export type ClientRest = object extends TClientContext ? undefined extends TInput @@ -24,9 +23,11 @@ export interface Client): PromiseWithError } -export type NestedClient = Client | { - [k: string]: NestedClient -} +export type NestedClient = + | Client + | { + [k: string]: NestedClient + } export type AnyNestedClient = NestedClient @@ -35,7 +36,8 @@ export type AnyNestedClient = NestedClient * * @see {@link https://orpc.dev/docs/client/client-side#infer-client-context | Client-Side Clients - Infer Client Context} */ -export type InferClientContext = T extends NestedClient ? U : never +export type InferClientContext = + T extends NestedClient ? U : never export interface ClientLink { call: (path: string[], input: unknown, options: ClientOptions) => Promise @@ -48,8 +50,8 @@ export interface ClientLink { * * @see {@link https://orpc.dev/docs/client/client-side#infer-client-inputs | Client-Side Clients - Infer Client Inputs} */ -export type InferClientInputs - = T extends Client +export type InferClientInputs = + T extends Client ? U : { [K in keyof T]: T[K] extends AnyNestedClient ? InferClientInputs : never @@ -63,9 +65,11 @@ export type InferClientInputs * * @see {@link https://orpc.dev/docs/client/client-side#infer-client-body-inputs | Client-Side Clients - Infer Client Body Inputs} */ -export type InferClientBodyInputs - = T extends Client - ? U extends { body: infer UBody } ? UBody : U +export type InferClientBodyInputs = + T extends Client + ? U extends { body: infer UBody } + ? UBody + : U : { [K in keyof T]: T[K] extends AnyNestedClient ? InferClientBodyInputs : never } @@ -77,8 +81,8 @@ export type InferClientBodyInputs * * @see {@link https://orpc.dev/docs/client/client-side#infer-client-outputs | Client-Side Clients - Infer Client Outputs} */ -export type InferClientOutputs - = T extends Client +export type InferClientOutputs = + T extends Client ? U : { [K in keyof T]: T[K] extends AnyNestedClient ? InferClientOutputs : never @@ -92,9 +96,11 @@ export type InferClientOutputs * * @see {@link https://orpc.dev/docs/client/client-side#infer-client-body-outputs | Client-Side Clients - Infer Client Body Outputs} */ -export type InferClientBodyOutputs - = T extends Client - ? U extends { body: infer UBody } ? UBody : U +export type InferClientBodyOutputs = + T extends Client + ? U extends { body: infer UBody } + ? UBody + : U : { [K in keyof T]: T[K] extends AnyNestedClient ? InferClientBodyOutputs : never } @@ -106,8 +112,8 @@ export type InferClientBodyOutputs * * @see {@link https://orpc.dev/docs/client/client-side#infer-client-errors | Client-Side Clients - Infer Client Errors} */ -export type InferClientErrors - = T extends Client +export type InferClientErrors = + T extends Client ? U : { [K in keyof T]: T[K] extends AnyNestedClient ? InferClientErrors : never @@ -120,8 +126,8 @@ export type InferClientErrors * * @see {@link https://orpc.dev/docs/client/client-side#infer-client-error | Client-Side Clients - Infer Client Error} */ -export type InferClientError - = T extends Client +export type InferClientError = + T extends Client ? U : { [K in keyof T]: T[K] extends AnyNestedClient ? InferClientError : never diff --git a/packages/client/src/utils.test-d.ts b/packages/client/src/utils.test-d.ts index 79d58d1870..2120cd62e1 100644 --- a/packages/client/src/utils.test-d.ts +++ b/packages/client/src/utils.test-d.ts @@ -1,10 +1,15 @@ -import type { ORPCError } from './error' -import type { Client, ClientContext } from './types' -import { isDefinedError } from './error-utils' -import { safe } from './utils' +import { isDefinedError } from './error-utils.ts' +import type { ORPCError } from './error.ts' +import type { Client, ClientContext } from './types.ts' +import { safe } from './utils.ts' describe('safe', async () => { - const client = {} as Client> + const client = {} as Client< + ClientContext, + string, + number, + Error | ORPCError<'BAD_GATEWAY', { val: string }> + > it('tuple style', async () => { const [error, data, definedError, isSuccess] = await safe(client('123')) @@ -21,14 +26,12 @@ describe('safe', async () => { if (definedError) { expectTypeOf(error).toEqualTypeOf>() expectTypeOf(definedError).toEqualTypeOf>() - } - else { + } else { // TODO: FIX IT - ORPCError should not showing here expectTypeOf(error).toEqualTypeOf>() expectTypeOf(definedError).toEqualTypeOf() } - } - else { + } else { expectTypeOf(error).toEqualTypeOf() expectTypeOf(data).toEqualTypeOf() expectTypeOf(definedError).toEqualTypeOf() @@ -50,14 +53,12 @@ describe('safe', async () => { if (definedError) { expectTypeOf(error).toEqualTypeOf>() expectTypeOf(definedError).toEqualTypeOf>() - } - else { + } else { // TODO: FIX IT - ORPCError should not showing here expectTypeOf(error).toEqualTypeOf>() expectTypeOf(definedError).toEqualTypeOf() } - } - else { + } else { expectTypeOf(error).toEqualTypeOf() expectTypeOf(data).toEqualTypeOf() expectTypeOf(definedError).toEqualTypeOf() diff --git a/packages/client/src/utils.test.ts b/packages/client/src/utils.test.ts index 5212f2577c..0a06f73bf4 100644 --- a/packages/client/src/utils.test.ts +++ b/packages/client/src/utils.test.ts @@ -1,19 +1,28 @@ -import { ORPCError } from './error' -import { resolveClientRest, resolveFriendlyClientOptions, safe } from './utils' +import { ORPCError } from './error.ts' +import { resolveClientRest, resolveFriendlyClientOptions, safe } from './utils.ts' 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' }) + 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(['123', { context: { a: 1 } }])).toEqual([ + '123', + { context: { a: 1 } }, + ]) + expect(resolveClientRest(['123', { lastEventId: '123' }])).toEqual([ + '123', + { context: {}, lastEventId: '123' }, + ]) expect(resolveClientRest([])).toEqual([undefined, { context: {} }]) }) }) @@ -21,21 +30,33 @@ describe('resolveClientRest', () => { it('safe', async () => { const r1 = await safe(Promise.resolve(1)) expect([...r1]).toEqual([null, 1, null, true]) - expect({ ...r1 }).toEqual(expect.objectContaining({ error: null, data: 1, definedError: null, isSuccess: true })) + // oxlint-disable-next-line typescript/no-misused-spread + expect({ ...r1 }).toEqual( + expect.objectContaining({ error: null, data: 1, definedError: null, isSuccess: true }), + ) const e2 = new Error('error') const r2 = await safe(Promise.reject(e2)) expect([...r2]).toEqual([e2, undefined, null, false]) - expect({ ...r2 }).toEqual(expect.objectContaining({ error: e2, data: undefined, definedError: null, isSuccess: false })) + // oxlint-disable-next-line typescript/no-misused-spread + expect({ ...r2 }).toEqual( + expect.objectContaining({ error: e2, data: undefined, definedError: null, isSuccess: false }), + ) const e3 = new ORPCError('BAD_GATEWAY') ;(e3 as any).defined = true // simulate defined error const r3 = await safe(Promise.reject(e3)) expect([...r3]).toEqual([e3, undefined, e3, false]) - expect({ ...r3 }).toEqual(expect.objectContaining({ error: e3, data: undefined, definedError: e3, isSuccess: false })) + // oxlint-disable-next-line typescript/no-misused-spread + expect({ ...r3 }).toEqual( + expect.objectContaining({ error: e3, data: undefined, definedError: e3, isSuccess: false }), + ) const e4 = new ORPCError('BAD_GATEWAY') const r4 = await safe(Promise.reject(e4)) expect([...r4]).toEqual([e4, undefined, null, false]) - expect({ ...r4 }).toEqual(expect.objectContaining({ error: e4, data: undefined, definedError: null, isSuccess: false })) + // oxlint-disable-next-line typescript/no-misused-spread + expect({ ...r4 }).toEqual( + expect.objectContaining({ error: e4, data: undefined, definedError: null, isSuccess: false }), + ) }) diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts index 7f4f3a1470..0421887e5c 100644 --- a/packages/client/src/utils.ts +++ b/packages/client/src/utils.ts @@ -1,29 +1,56 @@ import type { PromiseWithError, ThrowableError } from '@orpc/shared' -import type { AnyORPCError } from './error' -import type { ClientContext, ClientOptions, ClientRest, FriendlyClientOptions } from './types' -import { isDefinedError } from './error-utils' -export function resolveFriendlyClientOptions(options: FriendlyClientOptions): ClientOptions { +import { isDefinedError } from './error-utils.ts' +import type { AnyORPCError } from './error.ts' +import type { ClientContext, ClientOptions, ClientRest, FriendlyClientOptions } from './types.ts' + +export function resolveFriendlyClientOptions( + options: FriendlyClientOptions, +): ClientOptions { return { ...options, - context: options.context ?? {} as T, // Context only optional if all fields are optional + context: options.context ?? ({} as T), // Context only optional if all fields are optional } } -export function resolveClientRest(rest: ClientRest): [input: TInput, options: ClientOptions] { +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 + resolveFriendlyClientOptions(rest[1] ?? ({} as FriendlyClientOptions)), // rest[1] can be undefined if all fields of FriendlyClientOptions are optional ] } -export type SafeResult - = | [error: null, data: TOutput, definedError: null, isSuccess: true] - & { error: null, data: TOutput, definedError: null, isSuccess: true } - | [error: Exclude, data: undefined, definedError: null, isSuccess: false] - & { error: Exclude, data: undefined, definedError: null, isSuccess: false } - | [error: Extract, data: undefined, definedError: Extract, isSuccess: false] - & { error: Extract, data: undefined, definedError: Extract, isSuccess: false } +export type SafeResult = + | ([error: null, data: TOutput, definedError: null, isSuccess: true] & { + error: null + data: TOutput + definedError: null + isSuccess: true + }) + | ([ + error: Exclude, + data: undefined, + definedError: null, + isSuccess: false, + ] & { + error: Exclude + data: undefined + definedError: null + isSuccess: false + }) + | ([ + error: Extract, + data: undefined, + definedError: Extract, + isSuccess: false, + ] & { + error: Extract + data: undefined + definedError: Extract + isSuccess: false + }) /** * Works like try/catch, but help you infer the error type if it is a defined ORPCError. @@ -40,15 +67,18 @@ export type SafeResult * * @see {@link https://orpc.dev/docs/client/error-handling#using-safe-and-isdefinederror | Client Error Handling - Using safe and isDefinedError} */ -export async function safe(promise: PromiseWithError): Promise> { +export async function safe( + promise: PromiseWithError, +): Promise> { try { const output = await promise - return Object.assign( - [null, output, null, true] satisfies [null, TOutput, null, true], - { error: null, data: output, definedError: null, isSuccess: true as const }, - ) - } - catch (e) { + return Object.assign([null, output, null, true] satisfies [null, TOutput, null, true], { + error: null, + data: output, + definedError: null, + isSuccess: true as const, + }) + } catch (e) { const error = e as TError if (isDefinedError(error)) { @@ -59,8 +89,18 @@ export async function safe(promise: PromiseWit } return Object.assign( - [error as Exclude, undefined, null, false] satisfies [Exclude, undefined, null, false], - { error: error as Exclude, data: undefined, definedError: null, isSuccess: false as const }, + [error as Exclude, undefined, null, false] satisfies [ + Exclude, + undefined, + null, + false, + ], + { + error: error as Exclude, + data: undefined, + definedError: null, + isSuccess: false as const, + }, ) } } diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index ad530f45f4..9979a0715c 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -1,15 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.bench.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 11f1344976..c465eda1b3 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/cloudflare/build.config.ts b/packages/cloudflare/build.config.ts deleted file mode 100644 index 5c5c6f6a0b..0000000000 --- a/packages/cloudflare/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: ctx => verifyDist(ctx, ['cloudflare:workers']), - }, -}) diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 956afe3a69..9a5ab5131a 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,53 +1,52 @@ { "name": "@orpc/cloudflare", - "type": "module", "version": "2.0.0-beta.48", "description": "Cloudflare integration for oRPC: Durable Object pub/sub and locking, Workers rate limiting, and Workers Traces adapters", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" - ], - "homepage": "https://orpc.dev", - "repository": { - "type": "git", - "url": "git+https://github.com/middleapi/orpc.git", - "directory": "packages/cloudflare" - }, "keywords": [ - "orpc", "cloudflare", "cloudflare-workers", "durable-objects", - "ratelimit", "lock", + "observability", + "orpc", "pubsub", + "ratelimit", "tracing", - "observability", "typescript" ], + "homepage": "https://orpc.dev", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/orpc.git", + "directory": "packages/cloudflare" + }, + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { "prepare": "wrangler types", - "prepack": "obuild", - "type:check": "wrangler types && tsc -b", + "prepack": "tsc -p tsconfig.build.json", "test": "vitest run", "test:coverage": "vitest run --coverage" }, diff --git a/packages/cloudflare/src/index.test.ts b/packages/cloudflare/src/index.test.ts index 06947744c4..5f9888e8f3 100644 --- a/packages/cloudflare/src/index.test.ts +++ b/packages/cloudflare/src/index.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' it('exports CloudflareRateLimiter, experimental_CloudflareTracer, DurablePublisher, DurablePublisherObject, experimental_DurableLocker, experimental_DurableLockObject', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ CloudflareRateLimiter: expect.any(Function), experimental_CloudflareTracer: expect.any(Function), DurablePublisher: expect.any(Function), diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 2ec6999360..e154614a70 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,7 +1,7 @@ -export * from './lock' -export * from './lock-object' -export * from './publisher' -export * from './publisher-object' -export * from './ratelimit' -export type { experimental_CloudflareTracerOptions } from './tracer' -export { experimental_CloudflareTracer } from './tracer' +export * from './lock.ts' +export * from './lock-object.ts' +export * from './publisher.ts' +export * from './publisher-object.ts' +export * from './ratelimit.ts' +export type { experimental_CloudflareTracerOptions } from './tracer.ts' +export { experimental_CloudflareTracer } from './tracer.ts' diff --git a/packages/cloudflare/src/lock-object.test.ts b/packages/cloudflare/src/lock-object.test.ts index 43aa392471..e7449ef40a 100644 --- a/packages/cloudflare/src/lock-object.test.ts +++ b/packages/cloudflare/src/lock-object.test.ts @@ -1,9 +1,10 @@ -import type { experimental_DurableLockObject as DurableLockObject } from './lock-object' import { sleep } from '@orpc/shared' import { evictDurableObject, runInDurableObject } from 'cloudflare:test' import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' +import type { experimental_DurableLockObject as DurableLockObject } from './lock-object.ts' + describe('durableLockObject', () => { function createStub() { return env.LOCK_DON.getByName(crypto.randomUUID()) @@ -40,14 +41,17 @@ describe('durableLockObject', () => { } async function waitForSockets(stub: DurableObjectStub, count: number) { - await vi.waitFor(async () => { - const open = await runInDurableObject( - stub, - (_, ctx) => ctx.getWebSockets().filter(ws => ws.readyState === WebSocket.OPEN).length, - ) - - expect(open).toBe(count) - }, { interval: 10 }) + await vi.waitFor( + async () => { + const open = await runInDurableObject( + stub, + (_, ctx) => ctx.getWebSockets().filter((ws) => ws.readyState === WebSocket.OPEN).length, + ) + + expect(open).toBe(count) + }, + { interval: 10 }, + ) } it('grants the first socket right away and hands over to parked sockets in order', async () => { @@ -101,14 +105,17 @@ describe('durableLockObject', () => { // The runtime still lists a socket while it is closing, which only happens // inside the close event itself, so drive that state directly. - await runInDurableObject(stub as unknown as DurableObjectStub, (instance, ctx) => { - const [, staleWs, holderWs] = ctx.getWebSockets() // newest first + await runInDurableObject( + stub as unknown as DurableObjectStub, + (instance, ctx) => { + const [, staleWs, holderWs] = ctx.getWebSockets() // newest first - staleWs!.close() - holderWs!.close() + staleWs!.close() + holderWs!.close() - instance.webSocketClose(holderWs!) - }) + instance.webSocketClose(holderWs!) + }, + ) await vi.waitFor(() => expect(waiter.granted).toHaveBeenCalled()) expect(stale.granted).not.toHaveBeenCalled() diff --git a/packages/cloudflare/src/lock-object.ts b/packages/cloudflare/src/lock-object.ts index e328878b92..b83f4d272a 100644 --- a/packages/cloudflare/src/lock-object.ts +++ b/packages/cloudflare/src/lock-object.ts @@ -7,9 +7,12 @@ import { DurableObject } from 'cloudflare:workers' * * @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters} */ -export class experimental_DurableLockObject extends DurableObject { +export class experimental_DurableLockObject< + Env = Cloudflare.Env, + Props = unknown, +> extends DurableObject { override fetch(): Response { - const held = this.ctx.getWebSockets().some(ws => ws.readyState === WebSocket.OPEN) + const held = this.ctx.getWebSockets().some((ws) => ws.readyState === WebSocket.OPEN) const { '0': client, '1': server } = new WebSocketPair() server.serializeAttachment({ holder: !held }) @@ -29,7 +32,8 @@ export class experimental_DurableLockObject= 0; i--) { // oldest first + for (let i = sockets.length - 1; i >= 0; i--) { + // oldest first const next = sockets[i]! if (next.readyState !== WebSocket.OPEN) { diff --git a/packages/cloudflare/src/lock.test.ts b/packages/cloudflare/src/lock.test.ts index 898b62e25b..399a1e6862 100644 --- a/packages/cloudflare/src/lock.test.ts +++ b/packages/cloudflare/src/lock.test.ts @@ -4,7 +4,8 @@ import { promiseWithResolvers, sleep } from '@orpc/shared' import { runInDurableObject } from 'cloudflare:test' import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' -import { experimental_DurableLocker as DurableLocker } from './lock' + +import { experimental_DurableLocker as DurableLocker } from './lock.ts' describe('durableLocker', () => { function createTestingLocker( @@ -29,11 +30,15 @@ describe('durableLocker', () => { async function hold(locker: DurableLocker, key = 'key', options?: LockOptions) { const started = promiseWithResolvers() const finished = promiseWithResolvers() - const done = locker.lock(key, async ({ waited }) => { - started.resolve() - await finished.promise - return waited - }, options) + const done = locker.lock( + key, + async ({ waited }) => { + started.resolve() + await finished.promise + return waited + }, + options, + ) await Promise.race([started.promise, done]) @@ -46,27 +51,34 @@ describe('durableLocker', () => { } async function waitForSockets(prefix: string, count: number) { - await vi.waitFor(async () => { - const open = await runInDurableObject( - env.LOCK_DON.getByName(`${prefix}key`), - (_, ctx) => ctx.getWebSockets().filter(ws => ws.readyState === WebSocket.OPEN).length, - ) - - expect(open).toBe(count) - }, { interval: 10 }) + await vi.waitFor( + async () => { + const open = await runInDurableObject( + env.LOCK_DON.getByName(`${prefix}key`), + (_, ctx) => ctx.getWebSockets().filter((ws) => ws.readyState === WebSocket.OPEN).length, + ) + + expect(open).toBe(count) + }, + { interval: 10 }, + ) } it('runs the callback immediately when the lock is free and releases afterwards', async () => { const { locker } = createTestingLocker() const fn = vi.fn(async () => { - await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) + await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf( + LockTimeoutError, + ) return 'ok' }) await expect(locker.lock('key', fn)).resolves.toBe('ok') expect(fn).toHaveBeenCalledExactlyOnceWith({ waited: false }) - await vi.waitFor(() => expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again')) + await vi.waitFor(() => + expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again'), + ) }) it('waits for the holder to release', async () => { @@ -87,7 +99,9 @@ describe('durableLocker', () => { const { locker: other } = createTestingLocker({ prefix }) const holder = await hold(locker) - await expect(other.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) + await expect(other.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf( + LockTimeoutError, + ) await holder.release() await expect(other.lock('key', () => 'ok')).resolves.toBe('ok') @@ -97,8 +111,12 @@ describe('durableLocker', () => { const { locker } = createTestingLocker() const holder = await hold(locker, 'tenant:key') - await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) - await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + await expect( + locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 }), + ).rejects.toBeInstanceOf(LockTimeoutError) + await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe( + 'ok', + ) await holder.release() }) @@ -117,23 +135,32 @@ describe('durableLocker', () => { it('releases the lock when the callback throws', async () => { const { locker } = createTestingLocker() - await expect(locker.lock('key', () => { - throw new Error('boom') - })).rejects.toThrow('boom') + await expect( + locker.lock('key', () => { + throw new Error('boom') + }), + ).rejects.toThrow('boom') - await vi.waitFor(() => expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok')) + await vi.waitFor(() => + expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok'), + ) }) it.each([ { case: 'gives up immediately', attempt: (locker: DurableLocker, fn: () => unknown) => locker.lock('key', fn, { timeout: 0 }), - rejection: { name: 'LockTimeoutError', key: 'key', message: 'Timed out waiting for the lock of key "key"' }, + rejection: { + name: 'LockTimeoutError', + key: 'key', + message: 'Timed out waiting for the lock of key "key"', + }, minWait: 0, }, { case: 'times out while waiting', - attempt: (locker: DurableLocker, fn: () => unknown) => locker.lock('key', fn, { timeout: 200 }), + attempt: (locker: DurableLocker, fn: () => unknown) => + locker.lock('key', fn, { timeout: 200 }), rejection: { name: 'LockTimeoutError', key: 'key' }, minWait: 150, }, @@ -148,23 +175,26 @@ describe('durableLocker', () => { rejection: { message: 'aborted' }, minWait: 50, }, - ])('releases the socket of a waiter that $case, so the next waiter still gets the lock', async ({ attempt, rejection, minWait }) => { - const { prefix, locker } = createTestingLocker() - const holder = await hold(locker) - const fn = vi.fn() - const start = Date.now() - - await expect(attempt(locker, fn)).rejects.toMatchObject(rejection) - expect(Date.now() - start).toBeGreaterThanOrEqual(minWait) - expect(fn).not.toHaveBeenCalled() - await waitForSockets(prefix, 1) - - const waiter = locker.lock('key', ({ waited }) => waited, { timeout: 1000 }) - - await waitForSockets(prefix, 2) - await holder.release() - await expect(waiter).resolves.toBe(true) - }) + ])( + 'releases the socket of a waiter that $case, so the next waiter still gets the lock', + async ({ attempt, rejection, minWait }) => { + const { prefix, locker } = createTestingLocker() + const holder = await hold(locker) + const fn = vi.fn() + const start = Date.now() + + await expect(attempt(locker, fn)).rejects.toMatchObject(rejection) + expect(Date.now() - start).toBeGreaterThanOrEqual(minWait) + expect(fn).not.toHaveBeenCalled() + await waitForSockets(prefix, 1) + + const waiter = locker.lock('key', ({ waited }) => waited, { timeout: 1000 }) + + await waitForSockets(prefix, 2) + await holder.release() + await expect(waiter).resolves.toBe(true) + }, + ) it('hands the lock over when the ttl expires, and the expired holder cannot release it', async () => { const { locker } = createTestingLocker({ ttl: 200 }) @@ -172,21 +202,29 @@ describe('durableLocker', () => { const next = await hold(locker, 'key', { ttl: 10_000 }) await expect(expired.release()).resolves.toBe(false) - await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) + await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf( + LockTimeoutError, + ) await expect(next.release()).resolves.toBe(true) - await vi.waitFor(() => expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again')) + await vi.waitFor(() => + expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again'), + ) }) it('releases the lock when the ttl expires while the callback is still running', async () => { const { locker } = createTestingLocker({ ttl: 100 }) - await expect(locker.lock('key', async () => { - await sleep(300) - return 'ok' - })).resolves.toBe('ok') + await expect( + locker.lock('key', async () => { + await sleep(300) + return 'ok' + }), + ).resolves.toBe('ok') - await vi.waitFor(() => expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again')) + await vi.waitFor(() => + expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again'), + ) }) it('makes one request per lock and never polls', async () => { @@ -215,13 +253,19 @@ describe('durableLocker', () => { }) it('throws when the object does not return a socket', async () => { - const getStubByName = vi.fn(() => ({ - fetch: async () => new Response(null, { status: 500, statusText: 'Internal Server Error' }), - }) as unknown as DurableObjectStub) + const getStubByName = vi.fn( + () => + ({ + fetch: async () => + new Response(null, { status: 500, statusText: 'Internal Server Error' }), + }) as unknown as DurableObjectStub, + ) const { locker } = createTestingLocker({ getStubByName }) const fn = vi.fn() - await expect(locker.lock('key', fn)).rejects.toThrow('Failed to acquire the lock: 500 Internal Server Error') + await expect(locker.lock('key', fn)).rejects.toThrow( + 'Failed to acquire the lock: 500 Internal Server Error', + ) expect(fn).not.toHaveBeenCalled() }) @@ -230,7 +274,9 @@ describe('durableLocker', () => { const holder = await hold(locker) const fn = vi.fn() // Assert before closing the socket, so the rejection never sits unobserved - const rejected = expect(locker.lock('key', fn)).rejects.toThrow('The lock durable object closed the socket before handing the lock over') + const rejected = expect(locker.lock('key', fn)).rejects.toThrow( + 'The lock durable object closed the socket before handing the lock over', + ) await waitForSockets(prefix, 2) await runInDurableObject(env.LOCK_DON.getByName(`${prefix}key`), (_, ctx) => { @@ -264,14 +310,18 @@ describe('durableLocker', () => { let maxActive = 0 let count = 0 - await Promise.all(Array.from({ length: 5 }, () => locker.lock('key', async () => { - active++ - maxActive = Math.max(maxActive, active) - const current = count - await sleep(5) - count = current + 1 - active-- - }))) + await Promise.all( + Array.from({ length: 5 }, () => + locker.lock('key', async () => { + active++ + maxActive = Math.max(maxActive, active) + const current = count + await sleep(5) + count = current + 1 + active-- + }), + ), + ) expect(maxActive).toBe(1) expect(count).toBe(5) diff --git a/packages/cloudflare/src/lock.ts b/packages/cloudflare/src/lock.ts index 45391a99dc..c6d673474e 100644 --- a/packages/cloudflare/src/lock.ts +++ b/packages/cloudflare/src/lock.ts @@ -1,6 +1,6 @@ import type { LockCallbackOptions, Locker, LockOptions } from '@orpc/experimental-lock' -import type { Promisable } from '@orpc/shared' import { LockTimeoutError } from '@orpc/experimental-lock' +import type { Promisable } from '@orpc/shared' import { promiseWithResolvers, runWithSignal, throwIfAborted } from '@orpc/shared' export interface experimental_DurableLockerOptions { @@ -31,7 +31,9 @@ export interface experimental_DurableLockerOptions { * * @default ((namespace, key) => namespace.getByName(key)) */ - getStubByName?: ((namespace: DurableObjectNamespace, key: string) => DurableObjectStub) | undefined + getStubByName?: + | ((namespace: DurableObjectNamespace, key: string) => DurableObjectStub) + | undefined } /** @@ -45,7 +47,10 @@ export class experimental_DurableLocker implements Locker { private readonly keyPrefix: string private readonly ttl: number private readonly timeout: number - private readonly getStubByName: Exclude + private readonly getStubByName: Exclude< + experimental_DurableLockerOptions['getStubByName'], + undefined + > constructor( private readonly namespace: DurableObjectNamespace, @@ -63,16 +68,25 @@ export class experimental_DurableLocker implements Locker { * @see {@link https://orpc.dev/docs/helpers/lock#adapters | Lock Helpers - Adapters} */ prefix(prefix: string): experimental_DurableLocker { - // eslint-disable-next-line new-cap - return new experimental_DurableLocker(this.namespace, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + return new experimental_DurableLocker(this.namespace, { + ...this.options, + prefix: `${this.keyPrefix}${prefix}`, + }) } - async lock(key: string, fn: (options: LockCallbackOptions) => Promisable, options: LockOptions = {}): Promise { + async lock( + key: string, + fn: (options: LockCallbackOptions) => Promisable, + options: LockOptions = {}, + ): Promise { throwIfAborted(options.signal) - const response = await this.getStubByName(this.namespace, `${this.keyPrefix}${key}`).fetch('http://localhost/acquire', { - headers: { upgrade: 'websocket' }, - }) + const response = await this.getStubByName(this.namespace, `${this.keyPrefix}${key}`).fetch( + 'http://localhost/acquire', + { + headers: { upgrade: 'websocket' }, + }, + ) const websocket = response.webSocket @@ -103,8 +117,14 @@ export class experimental_DurableLocker implements Locker { const timer = setTimeout(() => granted.reject(new LockTimeoutError(key)), timeout) websocket.addEventListener('message', () => granted.resolve()) - websocket.addEventListener('close', () => granted.reject(new Error('The lock durable object closed the socket before handing the lock over'))) - websocket.addEventListener('error', event => granted.reject(new Error('Lock websocket error', { cause: event }))) + websocket.addEventListener('close', () => + granted.reject( + new Error('The lock durable object closed the socket before handing the lock over'), + ), + ) + websocket.addEventListener('error', (event) => + granted.reject(new Error('Lock websocket error', { cause: event })), + ) await runWithSignal(options.signal, () => granted.promise) .catch((error) => { @@ -118,8 +138,7 @@ export class experimental_DurableLocker implements Locker { try { return await fn({ waited }) - } - finally { + } finally { clearTimeout(expiry) close() } diff --git a/packages/cloudflare/src/publisher-object.test.ts b/packages/cloudflare/src/publisher-object.test.ts index 7cb76d1bd1..262925e84e 100644 --- a/packages/cloudflare/src/publisher-object.test.ts +++ b/packages/cloudflare/src/publisher-object.test.ts @@ -60,7 +60,7 @@ async function readMessages(socket: OpenSocket, count: number): Promise expect(socket.messages).toHaveLength(count) }) - return socket.messages.map(message => JSON.parse(message) as T) + return socket.messages.map((message) => JSON.parse(message) as T) } async function getAlarm(stub: DurableObjectStub): Promise { @@ -121,8 +121,8 @@ describe('durable publisher object', () => { // the closing socket is still listed alongside the healthy one when publishing await runInDurableObject(stub, async (_, state) => { - const readyStates = state.getWebSockets().map(websocket => websocket.readyState) - expect(readyStates.sort()).toEqual([WebSocket.OPEN, WebSocket.CLOSING]) + const readyStates = state.getWebSockets().map((websocket) => websocket.readyState) + expect(readyStates.toSorted((a, b) => a - b)).toEqual([WebSocket.OPEN, WebSocket.CLOSING]) }) expect((await publish(stub, { data: { text: 'live event' } })).status).toBe(204) @@ -138,10 +138,14 @@ describe('durable publisher object', () => { const liveSubscriber = await openSocket(stub) expect((await publish(stub, { data: { text: 'first' } })).status).toBe(204) - expect((await publish(stub, { - data: { text: 'second' }, - meta: { id: 'client-id', comments: ['keep me'] }, - })).status).toBe(204) + expect( + ( + await publish(stub, { + data: { text: 'second' }, + meta: { id: 'client-id', comments: ['keep me'] }, + }) + ).status, + ).toBe(204) expect((await publish(stub, { data: { text: 'third' } })).status).toBe(204) const liveMessages = await readMessages<{ meta: { id: string } }>(liveSubscriber, 3) @@ -218,7 +222,7 @@ describe('durable publisher object', () => { const messages = await readMessages<{ data: { order: number } }>(subscriber, 4) - expect(messages.map(message => message.data.order)).toEqual([1, 2, 3, 4]) + expect(messages.map((message) => message.data.order)).toEqual([1, 2, 3, 4]) await closeSocket(subscriber) }) @@ -351,7 +355,10 @@ describe('durable publisher object', () => { // an accepted websocket would linger in the durable object with nobody // on the other end, making it look active forever - const websockets = await runInDurableObject(stub, async (_, state) => state.getWebSockets().length) + const websockets = await runInDurableObject( + stub, + async (_, state) => state.getWebSockets().length, + ) expect(websockets).toBe(0) }) @@ -386,16 +393,22 @@ describe('durable publisher object', () => { }) it.each([ - ['the id reaches the max value', (sql: SqlStorage) => { - sql.exec( - 'INSERT INTO "prefix:events" (id, payload) VALUES (?, ?)', - '9223372036854775807', - JSON.stringify({ data: { text: 'before-overflow' } }), - ) - }], - ['the table disappears under a running object', (sql: SqlStorage) => { - sql.exec('DROP TABLE "prefix:events"') - }], + [ + 'the id reaches the max value', + (sql: SqlStorage) => { + sql.exec( + 'INSERT INTO "prefix:events" (id, payload) VALUES (?, ?)', + '9223372036854775807', + JSON.stringify({ data: { text: 'before-overflow' } }), + ) + }, + ], + [ + 'the table disappears under a running object', + (sql: SqlStorage) => { + sql.exec('DROP TABLE "prefix:events"') + }, + ], ])('recreates the events table when %s', async (_name, breakTable) => { const stub = env.PUBLISHER_RESUME3S_DON.getByName(crypto.randomUUID()) vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -406,10 +419,12 @@ describe('durable publisher object', () => { expect((await publish(stub, { data: { text: 'recovered' } })).status).toBe(204) const resumeSubscriber = await openSocket(stub, '0') - expect(await readMessages(resumeSubscriber, 1)).toEqual([{ - data: { text: 'recovered' }, - meta: { id: expect.any(String) }, - }]) + expect(await readMessages(resumeSubscriber, 1)).toEqual([ + { + data: { text: 'recovered' }, + meta: { id: expect.any(String) }, + }, + ]) expect(resumeSubscriber.replayedEvents).toBe('1') await closeSocket(resumeSubscriber) @@ -436,7 +451,11 @@ describe('durable publisher object', () => { expect((await publish(stub, { data: { text: 'after' } })).status).toBe(204) const resumeSubscriber = await openSocket(stub, '0') - expect((await readMessages<{ data: { text: string } }>(resumeSubscriber, 2)).map(message => message.data.text)).toEqual(['kept', 'after']) + expect( + (await readMessages<{ data: { text: string } }>(resumeSubscriber, 2)).map( + (message) => message.data.text, + ), + ).toEqual(['kept', 'after']) await closeSocket(resumeSubscriber) }) @@ -456,7 +475,11 @@ describe('durable publisher object', () => { expect((await publish(stub, { data: { text: 'rejected' } })).status).toBe(400) const resumeSubscriber = await openSocket(stub, '0') - expect((await readMessages<{ data: { text: string } }>(resumeSubscriber, 1)).map(message => message.data.text)).toEqual(['kept']) + expect( + (await readMessages<{ data: { text: string } }>(resumeSubscriber, 1)).map( + (message) => message.data.text, + ), + ).toEqual(['kept']) await closeSocket(resumeSubscriber) }) @@ -544,10 +567,16 @@ describe('durable publisher object', () => { } const resumeSubscriber = await openSocket(stub, seen[2]!.meta.id) - const resumed = await readMessages<{ data: { text: string }, meta: { id: string } }>(resumeSubscriber, 2) + const resumed = await readMessages<{ data: { text: string }; meta: { id: string } }>( + resumeSubscriber, + 2, + ) expect(resumeSubscriber.replayedEvents).toBe('2') - expect(resumed.map(message => message.data.text)).toEqual(['after cleanup 1', 'after cleanup 2']) + expect(resumed.map((message) => message.data.text)).toEqual([ + 'after cleanup 1', + 'after cleanup 2', + ]) await closeSocket(resumeSubscriber) }) diff --git a/packages/cloudflare/src/publisher-object.ts b/packages/cloudflare/src/publisher-object.ts index 2b4aeccb7b..8a21b7e800 100644 --- a/packages/cloudflare/src/publisher-object.ts +++ b/packages/cloudflare/src/publisher-object.ts @@ -1,6 +1,6 @@ import type { PublisherResumeOptions } from '@orpc/publisher' -import type { EventMeta } from '@standard-server/core' import { isPlainObject, stringifyJSON } from '@orpc/shared' +import type { EventMeta } from '@standard-server/core' import { DurableObject } from 'cloudflare:workers' export interface DurablePublisherObjectResumeOptions extends PublisherResumeOptions { @@ -54,10 +54,17 @@ export interface DurablePublisherObjectOptions { * * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} */ -export class DurablePublisherObject extends DurableObject { +export class DurablePublisherObject extends DurableObject< + Env, + Props +> { private readonly resumeStorage: ResumeStorage - constructor(ctx: DurableObjectState, env: Env, options: DurablePublisherObjectOptions = {}) { + constructor( + ctx: DurableObjectState, + env: Env, + options: DurablePublisherObjectOptions = {}, + ) { super(ctx, env) this.resumeStorage = new ResumeStorage(ctx, options.resume) } @@ -75,8 +82,7 @@ export class DurablePublisherObject exten try { stringifiedPayload = this.resumeStorage.store(stringifiedPayload) - } - catch (e) { + } catch (e) { return new Response(String(e), { status: 400 }) } @@ -109,8 +115,12 @@ export class DurablePublisherObject exten }) } - override webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean): void | Promise { - } + override webSocketClose( + _ws: WebSocket, + _code: number, + _reason: string, + _wasClean: boolean, + ): void | Promise {} override async alarm(): Promise { await this.resumeStorage.alarm() @@ -177,8 +187,7 @@ class ResumeStorage { try { return insertEvent() - } - catch (e) { + } catch (e) { if (!isUnusableTableError(e)) { throw e } @@ -213,12 +222,15 @@ class ResumeStorage { * The alias must not be `id`: SQLite resolves ORDER BY to an output * alias before a table column, which would sort ids as text. */ - const result = this.ctx.storage.sql.exec(` + const result = this.ctx.storage.sql.exec( + ` SELECT CAST(id AS TEXT) AS event_id, payload FROM "${this.schemaPrefix}events" WHERE id > ? ORDER BY id ASC - `, lastEventId) + `, + lastEventId, + ) const events: string[] = [] for (const record of result.toArray()) { @@ -245,9 +257,11 @@ class ResumeStorage { return true } - const activePayloadsRow = this.ctx.storage.sql.exec(` + const activePayloadsRow = this.ctx.storage.sql + .exec(` SELECT 1 as has FROM "${this.schemaPrefix}events" LIMIT 1 - `).toArray() + `) + .toArray() if (activePayloadsRow.length) { return true } @@ -314,9 +328,12 @@ class ResumeStorage { this.lastCleanupTime = now // `stored_at` is in whole seconds, so round up to never delete an event before its ttl - this.ctx.storage.sql.exec(` + this.ctx.storage.sql.exec( + ` DELETE FROM "${this.schemaPrefix}events" WHERE stored_at < unixepoch() - ? - `, Math.ceil(this.ttl / 1000)) + `, + Math.ceil(this.ttl / 1000), + ) } private async initAlarm(): Promise { @@ -326,8 +343,7 @@ class ResumeStorage { * An alarm from a previous Durable Object instance may fire before the * next cleanup is due. In that case, reschedule it instead of reusing it. */ - const alarmFiresTooEarly = currentAlarm !== null - && currentAlarm < Date.now() + this.ttl + const alarmFiresTooEarly = currentAlarm !== null && currentAlarm < Date.now() + this.ttl if (currentAlarm === null || alarmFiresTooEarly) { await this.scheduleAlarm() @@ -359,5 +375,7 @@ function isSerializedPayload(value: unknown): value is SerializedPayload { } function isUnusableTableError(error: unknown): boolean { - return /SQLITE_(?:FULL|CORRUPT|NOTADB)|no such table|no such column|has no column named/.test(String(error)) + return /SQLITE_(?:FULL|CORRUPT|NOTADB)|no such table|no such column|has no column named/.test( + String(error), + ) } diff --git a/packages/cloudflare/src/publisher.test.ts b/packages/cloudflare/src/publisher.test.ts index 15a8d5c6be..4e3e3f0512 100644 --- a/packages/cloudflare/src/publisher.test.ts +++ b/packages/cloudflare/src/publisher.test.ts @@ -1,18 +1,20 @@ -import type { DurablePublisherOptions } from './publisher' import { RPCJsonSerializer } from '@orpc/client' import { sleep } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@standard-server/core' import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' -import { DurablePublisher } from './publisher' - -type MockSocket = WebSocket & EventTarget & { - accepted: boolean - closeCalls: Array<{ code: number | undefined, reason: string | undefined }> - sendMessage: (data: string) => void - sendClose: (code: number, reason?: string) => void - sendError: () => void -} + +import type { DurablePublisherOptions } from './publisher.ts' +import { DurablePublisher } from './publisher.ts' + +type MockSocket = WebSocket & + EventTarget & { + accepted: boolean + closeCalls: Array<{ code: number | undefined; reason: string | undefined }> + sendMessage: (data: string) => void + sendClose: (code: number, reason?: string) => void + sendError: () => void + } function makeSocket(): MockSocket { const socket = new EventTarget() as MockSocket @@ -40,10 +42,13 @@ function makeSocket(): MockSocket { function makeNamespace(socket: MockSocket, headers: HeadersInit = {}): DurableObjectNamespace { const stub = { - fetch: vi.fn(async () => ({ - webSocket: socket as unknown as WebSocket, - headers: new Headers(headers), - } as Response)), + fetch: vi.fn( + async () => + ({ + webSocket: socket as unknown as WebSocket, + headers: new Headers(headers), + }) as Response, + ), } as unknown as DurableObjectStub return { @@ -57,9 +62,12 @@ function setup(headers: HeadersInit = {}) { } async function waitForAccept(socket: MockSocket) { - await vi.waitFor(() => { - expect(socket.accepted).toBe(true) - }, { interval: 1 }) + await vi.waitFor( + () => { + expect(socket.accepted).toBe(true) + }, + { interval: 1 }, + ) } const serializer = new RPCJsonSerializer() @@ -69,7 +77,10 @@ function sendEvent(socket: MockSocket, text: string) { } describe('durable publisher', () => { - function createTestingPublisher(namespace: DurableObjectNamespace, options: DurablePublisherOptions = {}) { + function createTestingPublisher( + namespace: DurableObjectNamespace, + options: DurablePublisherOptions = {}, + ) { const prefix = `${crypto.randomUUID()}:` return { prefix, publisher: new DurablePublisher(namespace, { ...options, prefix }) } @@ -83,10 +94,16 @@ describe('durable publisher', () => { await publisher.publish('notice', { text: 'ignore me' }) await publisher.publish('message', { text: 'first' }) - await publisher.publish('message', withEventMeta({ text: 'second' }, { - id: 'client-id', - comments: ['keep me'], - })) + await publisher.publish( + 'message', + withEventMeta( + { text: 'second' }, + { + id: 'client-id', + comments: ['keep me'], + }, + ), + ) await vi.waitFor(() => { expect(live).toHaveBeenCalledTimes(2) @@ -132,10 +149,16 @@ describe('durable publisher', () => { await publisher.publish('notice', { text: 'ignore me' }) await publisher.publish('message', { text: 'first' }) - await publisher.publish('message', withEventMeta({ text: 'second' }, { - id: 'client-id', - comments: ['keep me'], - })) + await publisher.publish( + 'message', + withEventMeta( + { text: 'second' }, + { + id: 'client-id', + comments: ['keep me'], + }, + ), + ) await vi.waitFor(() => { expect(live).toHaveBeenCalledTimes(2) @@ -183,13 +206,15 @@ describe('durable publisher', () => { expect(listener).toHaveBeenCalledTimes(4) }) - expect(listener.mock.calls.map(call => call[0].order)).toEqual([1, 2, 3, 4]) + expect(listener.mock.calls.map((call) => call[0].order)).toEqual([1, 2, 3, 4]) await unsubscribe() }) it('caps resumed events at maxBufferedEvents for a slow iterator consumer', async () => { - const { publisher } = createTestingPublisher(env.PUBLISHER_RESUME3S_DON, { maxBufferedEvents: 2 }) + const { publisher } = createTestingPublisher(env.PUBLISHER_RESUME3S_DON, { + maxBufferedEvents: 2, + }) const subscribeListener = vi.spyOn(publisher as any, 'subscribeListener') for (let i = 0; i < 5; i++) { @@ -233,7 +258,7 @@ describe('durable publisher', () => { const serializer = new RPCJsonSerializer({ handlers: { person: { - condition: p => p instanceof Person, + condition: (p) => p instanceof Person, serialize: (p: Person) => ({ name: p.name, age: p.age }), deserialize: (data: any) => new Person(data.name, data.age), }, @@ -284,10 +309,13 @@ describe('durable publisher', () => { it('throws when publish fails', async () => { const stub = { - fetch: vi.fn(async () => new Response(null, { - status: 503, - statusText: 'Service Unavailable', - })), + fetch: vi.fn( + async () => + new Response(null, { + status: 503, + statusText: 'Service Unavailable', + }), + ), } as unknown as DurableObjectStub const namespace = { @@ -343,7 +371,9 @@ describe('durable publisher', () => { expect(onError).toHaveBeenCalledTimes(3) }) - expect(onError.mock.calls[0]![0].message).toBe('Failed to deserialize message from publisher durable object') + expect(onError.mock.calls[0]![0].message).toBe( + 'Failed to deserialize message from publisher durable object', + ) expect(onError.mock.calls[1]![0].message).toBe('WebSocket closed unexpectedly: 1011 crashed') expect(onError.mock.calls[2]![0].message).toBe('Subscription websocket error') @@ -359,10 +389,12 @@ describe('durable publisher', () => { const onError = vi.fn() let resolved = false - const subscription = publisher.subscribe('message', listener, { lastEventId: '0', onError }).then((unsubscribe) => { - resolved = true - return unsubscribe - }) + const subscription = publisher + .subscribe('message', listener, { lastEventId: '0', onError }) + .then((unsubscribe) => { + resolved = true + return unsubscribe + }) await waitForAccept(socket) @@ -374,14 +406,18 @@ describe('durable publisher', () => { sendEvent(socket, 'missed 2') const unsubscribe = await subscription - expect(listener.mock.calls.map(call => call[0].text)).toEqual(['missed 1', 'missed 2']) + expect(listener.mock.calls.map((call) => call[0].text)).toEqual(['missed 1', 'missed 2']) expect(onError).toHaveBeenCalledTimes(1) await unsubscribe() }) it.each([ - ['closes normally', (socket: MockSocket) => socket.sendClose(1000, 'done'), 'WebSocket closed unexpectedly: 1000 done'], + [ + 'closes normally', + (socket: MockSocket) => socket.sendClose(1000, 'done'), + 'WebSocket closed unexpectedly: 1000 done', + ], ['errors', (socket: MockSocket) => socket.sendError(), 'Subscription websocket error'], ])('rejects when the socket %s before the replay finishes', async (_, fail, message) => { const { socket, publisher } = setup({ 'orpc-replayed-events': '2' }) diff --git a/packages/cloudflare/src/publisher.ts b/packages/cloudflare/src/publisher.ts index 42a9bd05d3..66d34aa90f 100644 --- a/packages/cloudflare/src/publisher.ts +++ b/packages/cloudflare/src/publisher.ts @@ -1,7 +1,7 @@ -import type { PublisherOptions, PublisherSubscribeListenerOptions } from '@orpc/publisher' -import type { Public } from '@orpc/shared' import { RPCJsonSerializer } from '@orpc/client' +import type { PublisherOptions, PublisherSubscribeListenerOptions } from '@orpc/publisher' import { Publisher } from '@orpc/publisher' +import type { Public } from '@orpc/shared' import { isTypescriptObject, promiseWithResolvers, stringifyJSON } from '@orpc/shared' import { unwrapEvent, withEventMeta } from '@standard-server/core' @@ -25,7 +25,9 @@ export interface DurablePublisherOptions extends PublisherOptions { * * @default ((namespace, event) => namespace.getByName(event)) */ - getStubByName?: ((namespace: DurableObjectNamespace, event: string) => DurableObjectStub) | undefined + getStubByName?: + | ((namespace: DurableObjectNamespace, event: string) => DurableObjectStub) + | undefined } /** @@ -55,7 +57,10 @@ export class DurablePublisher> extends Publishe * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} */ prefix(prefix: string): DurablePublisher { - return new DurablePublisher(this.namespace, { ...this.options, prefix: `${this.eventPrefix}${prefix}` }) + return new DurablePublisher(this.namespace, { + ...this.options, + prefix: `${this.eventPrefix}${prefix}`, + }) } async publish(event: K, payload: T[K]): Promise { @@ -77,13 +82,20 @@ export class DurablePublisher> extends Publishe if (!response.ok) { const reason = await response.text() - throw new Error(`Failed to publish event: ${response.status} ${reason || response.statusText}`, { - cause: response, - }) + throw new Error( + `Failed to publish event: ${response.status} ${reason || response.statusText}`, + { + cause: response, + }, + ) } } - protected async subscribeListener(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> { + protected async subscribeListener( + event: K, + listener: (payload: T[K]) => void, + options?: PublisherSubscribeListenerOptions, + ): Promise<() => Promise> { const stub = this.getStubByName(this.namespace, this.eventPrefix + event) const headers = new Headers({ upgrade: 'websocket' }) @@ -105,7 +117,8 @@ export class DurablePublisher> extends Publishe // The Durable Object sends missed events first and their count in this header, // so wait for them all to reach the listener before this resolves const replayedEvents = Number(response.headers.get('orpc-replayed-events')) - let pendingReplayedEvents = Number.isInteger(replayedEvents) && replayedEvents > 0 ? replayedEvents : 0 + let pendingReplayedEvents = + Number.isInteger(replayedEvents) && replayedEvents > 0 ? replayedEvents : 0 const replayed = promiseWithResolvers() if (pendingReplayedEvents === 0) { @@ -115,8 +128,7 @@ export class DurablePublisher> extends Publishe const reportError = (error: Error) => { if (pendingReplayedEvents > 0) { replayed.reject(error) // no-op once the subscription already rejected - } - else { + } else { options?.onError?.(error) } } @@ -130,8 +142,7 @@ export class DurablePublisher> extends Publishe } listener(payload as T[K]) - } - catch (error) { + } catch (error) { options?.onError?.( new Error('Failed to deserialize message from publisher durable object', { cause: error, diff --git a/packages/cloudflare/src/ratelimit.test.ts b/packages/cloudflare/src/ratelimit.test.ts index 4ac9f4951e..91c92631ef 100644 --- a/packages/cloudflare/src/ratelimit.test.ts +++ b/packages/cloudflare/src/ratelimit.test.ts @@ -1,7 +1,8 @@ -import type { CloudflareRateLimiterOptions } from './ratelimit' import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' -import { CloudflareRateLimiter } from './ratelimit' + +import type { CloudflareRateLimiterOptions } from './ratelimit.ts' +import { CloudflareRateLimiter } from './ratelimit.ts' describe('cloudflareRateLimiter', () => { const PERIOD_MS = 10_000 @@ -20,9 +21,11 @@ describe('cloudflareRateLimiter', () => { try { await spend(`user:${crypto.randomUUID()}`) return - } - catch (error) { - if (remaining === 1 || Math.floor(started / PERIOD_MS) === Math.floor(Date.now() / PERIOD_MS)) { + } catch (error) { + if ( + remaining === 1 || + Math.floor(started / PERIOD_MS) === Math.floor(Date.now() / PERIOD_MS) + ) { throw error } } diff --git a/packages/cloudflare/src/ratelimit.ts b/packages/cloudflare/src/ratelimit.ts index 59d90a9025..c33ef29a7a 100644 --- a/packages/cloudflare/src/ratelimit.ts +++ b/packages/cloudflare/src/ratelimit.ts @@ -33,7 +33,10 @@ export class CloudflareRateLimiter implements RateLimiter { * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} */ prefix(prefix: string): CloudflareRateLimiter { - return new CloudflareRateLimiter(this.ratelimit, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + return new CloudflareRateLimiter(this.ratelimit, { + ...this.options, + prefix: `${this.keyPrefix}${prefix}`, + }) } async limit(key: string, options: RateLimitOptions = {}): Promise { diff --git a/packages/cloudflare/src/tracer.test.ts b/packages/cloudflare/src/tracer.test.ts index f9e8cbe09c..ba5e28312f 100644 --- a/packages/cloudflare/src/tracer.test.ts +++ b/packages/cloudflare/src/tracer.test.ts @@ -1,7 +1,11 @@ import type { Tracer } from '@orpc/shared' import { getTracer, setTracer } from '@orpc/shared' import { afterEach, describe, expect, it, vi } from 'vitest' -import { experimental_CloudflareSpan as CloudflareSpan, experimental_CloudflareTracer as CloudflareTracer } from './tracer' + +import { + experimental_CloudflareSpan as CloudflareSpan, + experimental_CloudflareTracer as CloudflareTracer, +} from './tracer.ts' function createFakeSpan() { return { @@ -11,7 +15,11 @@ function createFakeSpan() { recordException: vi.fn(), updateName: vi.fn().mockReturnThis(), setStatus: vi.fn().mockReturnThis(), - spanContext: vi.fn(() => ({ traceId: '4bf92f3577b34da6a3ce929d0e0e4736', spanId: '00f067aa0ba902b7', traceFlags: 0 })), + spanContext: vi.fn(() => ({ + traceId: '4bf92f3577b34da6a3ce929d0e0e4736', + spanId: '00f067aa0ba902b7', + traceFlags: 0, + })), end: vi.fn(), } } @@ -100,7 +108,11 @@ describe('cloudflareTracer', () => { it('injects nothing when the span context is invalid', () => { const fake = createFakeTracing() - fake.span.spanContext.mockReturnValue({ traceId: '00000000000000000000000000000000', spanId: '0000000000000000', traceFlags: 0 }) + fake.span.spanContext.mockReturnValue({ + traceId: '00000000000000000000000000000000', + spanId: '0000000000000000', + traceFlags: 0, + }) const tracer = new CloudflareTracer({ tracing: fake as any }) const headers = {} @@ -110,7 +122,10 @@ describe('cloudflareTracer', () => { }) it('has no inject method when propagation is disabled', () => { - const tracer: Tracer = new CloudflareTracer({ tracing: createFakeTracing() as any, propagationEnabled: false }) + const tracer: Tracer = new CloudflareTracer({ + tracing: createFakeTracing() as any, + propagationEnabled: false, + }) expect(tracer.inject).toBeUndefined() }) @@ -226,9 +241,11 @@ describe('cloudflareTracer', () => { it('rethrows errors from active spans', async () => { const tracer = new CloudflareTracer() - await expect(tracer.startActiveSpan('failing', undefined, async () => { - throw new Error('boom') - })).rejects.toThrow('boom') + await expect( + tracer.startActiveSpan('failing', undefined, async () => { + throw new Error('boom') + }), + ).rejects.toThrow('boom') }) }) }) diff --git a/packages/cloudflare/src/tracer.ts b/packages/cloudflare/src/tracer.ts index 12e7e46e9c..7d27305745 100644 --- a/packages/cloudflare/src/tracer.ts +++ b/packages/cloudflare/src/tracer.ts @@ -1,4 +1,11 @@ -import type { Promisable, Tracer, TracingAttributeValue, TracingException, TracingExceptionLevel, TracingSpan } from '@orpc/shared' +import type { + Promisable, + Tracer, + TracingAttributeValue, + TracingException, + TracingExceptionLevel, + TracingSpan, +} from '@orpc/shared' import { setTracer, toSpanAttributeValue } from '@orpc/shared' /** * A namespace import keeps `@orpc/cloudflare` loadable on runtimes that predate the @@ -92,8 +99,12 @@ export class experimental_CloudflareTracer implements Tracer { return new CloudflareSpan(this.tracing.startSpan(name)) } - startActiveSpan(name: string, _parent: TracingSpan | undefined, fn: (span: TracingSpan) => Promise): Promise { - return this.tracing.startActiveSpan(name, span => fn(new CloudflareSpan(span))) + startActiveSpan( + name: string, + _parent: TracingSpan | undefined, + fn: (span: TracingSpan) => Promise, + ): Promise { + return this.tracing.startActiveSpan(name, (span) => fn(new CloudflareSpan(span))) } getActiveSpan(): TracingSpan | undefined { diff --git a/packages/cloudflare/tests/__shared__/main.ts b/packages/cloudflare/tests/__shared__/main.ts index ea189dc10f..5c17c91d86 100644 --- a/packages/cloudflare/tests/__shared__/main.ts +++ b/packages/cloudflare/tests/__shared__/main.ts @@ -1,8 +1,7 @@ -import { experimental_DurableLockObject as DurableLockObject } from '../../src/lock-object' -import { DurablePublisherObject } from '../../src/publisher-object' +import { experimental_DurableLockObject as DurableLockObject } from '../../src/lock-object.ts' +import { DurablePublisherObject } from '../../src/publisher-object.ts' -export class PublisherDO extends DurablePublisherObject { -} +export class PublisherDO extends DurablePublisherObject {} export class PublisherResume3sDO extends DurablePublisherObject { constructor(ctx: DurableObjectState, env: Env) { @@ -17,5 +16,4 @@ export class PublisherResume3sDO extends DurablePublisherObject { } } -export class LockDO extends DurableLockObject { -} +export class LockDO extends DurableLockObject {} diff --git a/packages/cloudflare/tsconfig.build.json b/packages/cloudflare/tsconfig.build.json new file mode 100644 index 0000000000..85fb18b6e8 --- /dev/null +++ b/packages/cloudflare/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"], + // worker-configuration.d.ts imports the tests, so type checking runs with tsconfig.json only + "noCheck": true + } +} diff --git a/packages/cloudflare/tsconfig.json b/packages/cloudflare/tsconfig.json index 46ba7d79ea..3c41294100 100644 --- a/packages/cloudflare/tsconfig.json +++ b/packages/cloudflare/tsconfig.json @@ -1,16 +1,8 @@ { - "extends": "../../tsconfig.lib.json", + "extends": "../../tsconfig.json", "compilerOptions": { - "lib": ["ES2022"], - "types": [ - "node", - "@cloudflare/vitest-plugin/types" - ] + "types": ["node", "@cloudflare/vitest-plugin/types"] }, - "include": [ - "package.json", - "worker-configuration.d.ts", - "src", - "tests" - ] + "include": ["worker-configuration.d.ts", "vitest.config.ts", "src", "tests"], + "exclude": [] } diff --git a/packages/cloudflare/wrangler.jsonc b/packages/cloudflare/wrangler.jsonc index df0fa51117..16ce707e34 100644 --- a/packages/cloudflare/wrangler.jsonc +++ b/packages/cloudflare/wrangler.jsonc @@ -6,38 +6,33 @@ { "name": "RATELIMIT_3_10S", "namespace_id": "1001", - "simple": { "limit": 3, "period": 10 } - } + "simple": { "limit": 3, "period": 10 }, + }, ], "durable_objects": { "bindings": [ { "name": "PUBLISHER_DON", - "class_name": "PublisherDO" + "class_name": "PublisherDO", }, { "name": "PUBLISHER_RESUME3S_DON", - "class_name": "PublisherResume3sDO" + "class_name": "PublisherResume3sDO", }, { "name": "LOCK_DON", - "class_name": "LockDO" - } - ] + "class_name": "LockDO", + }, + ], }, "migrations": [ { "tag": "v1", - "new_sqlite_classes": [ - "PublisherDO", - "PublisherResume3sDO" - ] + "new_sqlite_classes": ["PublisherDO", "PublisherResume3sDO"], }, { "tag": "v2", - "new_sqlite_classes": [ - "LockDO" - ] - } - ] + "new_sqlite_classes": ["LockDO"], + }, + ], } diff --git a/packages/contract/README.md b/packages/contract/README.md index 4d2616fbda..ef9597255d 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/contract/build.config.ts b/packages/contract/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/contract/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/contract/package.json b/packages/contract/package.json index 168a457864..0a66bbb52e 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -1,57 +1,56 @@ { "name": "@orpc/contract", - "type": "module", "version": "2.0.0-beta.48", "description": "Define typesafe API contracts as the single source of truth for oRPC, powered by Standard Schema", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "api", + "contract", + "contract-first", + "openapi", + "orpc", + "rpc", + "standard-schema", + "typesafe", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/contract" }, - "keywords": [ - "orpc", - "rpc", - "api", - "typescript", - "typesafe", - "contract", - "contract-first", - "standard-schema", - "openapi" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./plugins": "./src/plugins/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./plugins": { - "types": "./dist/plugins/index.d.mts", - "import": "./dist/plugins/index.mjs", - "default": "./dist/plugins/index.mjs" + "types": "./dist/plugins/index.d.ts", + "import": "./dist/plugins/index.js", + "default": "./dist/plugins/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts", - "./plugins": "./src/plugins/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", diff --git a/packages/contract/src/builder-variants.test-d.ts b/packages/contract/src/builder-variants.test-d.ts index d0544f3da0..16ff217db7 100644 --- a/packages/contract/src/builder-variants.test-d.ts +++ b/packages/contract/src/builder-variants.test-d.ts @@ -1,10 +1,15 @@ -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' +import type { + ProcedureContractBuilderWithInput, + ProcedureContractBuilderWithInputOutput, + ProcedureContractBuilderWithOutput, +} from './builder-variants.ts' +import type { MergedErrorMap } from './error-utils.ts' +import type { Meta } from './meta.ts' +import type { MergedSchema, Schema } from './schema.ts' + const errorMap = { BASE: { data: z.object({ id: z.string() }), @@ -13,20 +18,17 @@ const errorMap = { } // 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}`) }) +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 - > + const builder = {} as ProcedureContractBuilderWithInput it('.errors', () => { expectTypeOf(builder.errors({ INVALID: { message: 'invalid' } })).toEqualTypeOf< ProcedureContractBuilderWithInput< - typeof schema1, - MergedErrorMap + typeof schema1, + MergedErrorMap > >() @@ -39,7 +41,7 @@ describe('ProcedureContractBuilderWithInput', () => { expectTypeOf(builder.meta(plugin)).toEqualTypeOf() // @ts-expect-error - invalid meta - builder.meta({ } as MetaPlugin, any, any>) + builder.meta({} as MetaPlugin, any, any>) }) it('.input', () => { @@ -58,11 +60,7 @@ describe('ProcedureContractBuilderWithInput', () => { it('.output', () => { expectTypeOf(builder.output(schema2)).toEqualTypeOf< - ProcedureContractBuilderWithInputOutput< - typeof schema1, - typeof schema2, - typeof errorMap - > + ProcedureContractBuilderWithInputOutput >() // @ts-expect-error - schema is invalid @@ -71,16 +69,13 @@ describe('ProcedureContractBuilderWithInput', () => { }) describe('ProcedureContractBuilderWithOutput', () => { - const builder = {} as ProcedureContractBuilderWithOutput< - typeof schema2, - typeof errorMap - > + const builder = {} as ProcedureContractBuilderWithOutput it('.errors', () => { expectTypeOf(builder.errors({ INVALID: { message: 'invalid' } })).toEqualTypeOf< ProcedureContractBuilderWithOutput< - typeof schema2, - MergedErrorMap + typeof schema2, + MergedErrorMap > >() @@ -93,16 +88,12 @@ describe('ProcedureContractBuilderWithOutput', () => { expectTypeOf(builder.meta(plugin)).toEqualTypeOf() // @ts-expect-error - invalid meta - builder.meta({ } as MetaPlugin, any, any>) + builder.meta({} as MetaPlugin, any, any>) }) it('.input', () => { expectTypeOf(builder.input(schema1)).toEqualTypeOf< - ProcedureContractBuilderWithInputOutput< - typeof schema1, - typeof schema2, - typeof errorMap - > + ProcedureContractBuilderWithInputOutput >() // @ts-expect-error - schema is invalid @@ -126,17 +117,17 @@ describe('ProcedureContractBuilderWithOutput', () => { describe('ProcedureContractBuilderWithInputOutput', () => { const builder = {} as ProcedureContractBuilderWithInputOutput< - typeof schema1, - typeof schema2, - typeof errorMap + typeof schema1, + typeof schema2, + typeof errorMap > it('.errors', () => { expectTypeOf(builder.errors({ INVALID: { message: 'invalid' } })).toEqualTypeOf< ProcedureContractBuilderWithInputOutput< - typeof schema1, - typeof schema2, - MergedErrorMap + typeof schema1, + typeof schema2, + MergedErrorMap > >() @@ -149,7 +140,7 @@ describe('ProcedureContractBuilderWithInputOutput', () => { expectTypeOf(builder.meta(plugin)).toEqualTypeOf() // @ts-expect-error - invalid meta - builder.meta({ } as MetaPlugin, any, any>) + builder.meta({} as MetaPlugin, any, any>) }) it('.input', () => { diff --git a/packages/contract/src/builder-variants.ts b/packages/contract/src/builder-variants.ts index f4a0d663c2..35cc299c61 100644 --- a/packages/contract/src/builder-variants.ts +++ b/packages/contract/src/builder-variants.ts @@ -1,9 +1,9 @@ -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' +import type { InitialInputSchema, InitialOutputSchema } from './builder.ts' +import type { MergedErrorMap } from './error-utils.ts' +import type { ErrorMap } from './error.ts' +import type { MetaPlugin } from './meta.ts' +import type { ProcedureContract } from './procedure.ts' +import type { AnySchema, MergedSchema } from './schema.ts' /** * The contract builder variant returned after `.input` is called. @@ -14,7 +14,7 @@ import type { AnySchema, MergedSchema } from './schema' export interface ProcedureContractBuilderWithInput< TInputSchema extends AnySchema, TErrorMap extends ErrorMap, ->extends ProcedureContract { +> extends ProcedureContract { /** * Applies metadata plugins to contracts built from this builder. * @@ -61,7 +61,7 @@ export interface ProcedureContractBuilderWithInput< export interface ProcedureContractBuilderWithOutput< TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, ->extends ProcedureContract { +> extends ProcedureContract { /** * Applies metadata plugins to contracts built from this builder. * @@ -110,7 +110,7 @@ export interface ProcedureContractBuilderWithInputOutput< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, ->extends ProcedureContract { +> extends ProcedureContract { /** * Applies metadata plugins to contracts built from this builder. * @@ -127,7 +127,11 @@ export interface ProcedureContractBuilderWithInputOutput< */ errors( errors: T, - ): ProcedureContractBuilderWithInputOutput> + ): ProcedureContractBuilderWithInputOutput< + TInputSchema, + TOutputSchema, + MergedErrorMap + > /** * Adds an additional input schema, merged with the previously defined one. @@ -136,7 +140,11 @@ export interface ProcedureContractBuilderWithInputOutput< */ input( schema: T, - ): ProcedureContractBuilderWithInputOutput, TOutputSchema, TErrorMap> + ): ProcedureContractBuilderWithInputOutput< + MergedSchema, + TOutputSchema, + TErrorMap + > /** * Adds an additional output schema, merged with the previously defined one. @@ -145,5 +153,9 @@ export interface ProcedureContractBuilderWithInputOutput< */ output( schema: T, - ): ProcedureContractBuilderWithInputOutput, TErrorMap> + ): ProcedureContractBuilderWithInputOutput< + TInputSchema, + MergedSchema, + TErrorMap + > } diff --git a/packages/contract/src/builder.test-d.ts b/packages/contract/src/builder.test-d.ts index b85588598e..60f92e76d5 100644 --- a/packages/contract/src/builder.test-d.ts +++ b/packages/contract/src/builder.test-d.ts @@ -1,13 +1,17 @@ -import type { ContractBuilder } from './builder' -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 { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' -import { oc } from './builder' + +import type { + ProcedureContractBuilderWithInput, + ProcedureContractBuilderWithOutput, +} from './builder-variants.ts' +import type { ContractBuilder } from './builder.ts' +import { oc } from './builder.ts' +import type { MergedErrorMap } from './error-utils.ts' +import type { Meta, MetaPlugin } from './meta.ts' +import type { ProcedureContract } from './procedure.ts' +import type { AugmentedContractRouter } from './router-utils.ts' +import type { Schema } from './schema.ts' const errorMap = { BASE: { @@ -19,24 +23,25 @@ const errorMap = { 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}`) }) +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).toExtend< - ProcedureContract< - Schema, - Schema, - Record - > + ProcedureContract, Schema, Record> >() }) it('.errors', () => { - expectTypeOf(builder.errors({ INVALID: { message: 'invalid' }, OVERRIDE: { message: 'override' } })).toEqualTypeOf< + expectTypeOf( + builder.errors({ INVALID: { message: 'invalid' }, OVERRIDE: { message: 'override' } }), + ).toEqualTypeOf< ContractBuilder< - MergedErrorMap + MergedErrorMap< + typeof errorMap, + { INVALID: { message: string }; OVERRIDE: { message: string } } + > > >() @@ -49,15 +54,12 @@ describe('ContractBuilder', () => { expectTypeOf(builder.meta(plugin)).toEqualTypeOf() // @ts-expect-error - invalid meta - builder.meta({ } as MetaPlugin, any, any>) + builder.meta({} as MetaPlugin, any, any>) }) it('.input', () => { expectTypeOf(builder.input(schema1)).toEqualTypeOf< - ProcedureContractBuilderWithInput< - typeof schema1, - typeof errorMap - > + ProcedureContractBuilderWithInput >() // @ts-expect-error - schema is invalid @@ -66,10 +68,7 @@ describe('ContractBuilder', () => { it('.output', () => { expectTypeOf(builder.output(schema2)).toEqualTypeOf< - ProcedureContractBuilderWithOutput< - typeof schema2, - typeof errorMap - > + ProcedureContractBuilderWithOutput >() // @ts-expect-error - schema is invalid diff --git a/packages/contract/src/builder.test.ts b/packages/contract/src/builder.test.ts index 6e8b02550e..c41ff37a0b 100644 --- a/packages/contract/src/builder.test.ts +++ b/packages/contract/src/builder.test.ts @@ -1,12 +1,13 @@ -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' +import * as z from 'zod' + +import { ContractBuilder, oc } from './builder.ts' +import * as ErrorUtilsModule from './error-utils.ts' +import type { ErrorMap } from './error.ts' +import * as MetaUtilsModule from './meta-utils.ts' +import type { AnyMetaPlugin } from './meta.ts' +import { setHiddenMetaPlugins } from './meta.ts' +import { ProcedureContract } from './procedure.ts' +import * as RouterUtilsModule from './router-utils.ts' const resolveMetaPluginsSpy = vi.spyOn(MetaUtilsModule, 'resolveMetaPlugins') const mergeErrorMapSpy = vi.spyOn(ErrorUtilsModule, 'mergeErrorMap') @@ -22,26 +23,22 @@ describe('contractBuilder', () => { errorMap: { BASE: { status: 400 }, }, - inputSchemas: [ - z.object({ init: z.string() }), - ], - outputSchemas: [ - z.object({ init: z.string() }), - ], + inputSchemas: [z.object({ init: z.string() })], + outputSchemas: [z.object({ init: z.string() })], meta: { base: true, }, metaPlugins: [ { name: 'test1', - init: m => m, + init: (m) => m, }, ], } const metaPlugin: AnyMetaPlugin = { name: 'test2', - init: m => ({ ...m, metaPlugin: true }), + init: (m) => ({ ...m, metaPlugin: true }), } it('is a procedure contract', () => { @@ -86,10 +83,7 @@ describe('contractBuilder', () => { expect(applied).not.toBe(builder) expect(mergeErrorMapSpy).toHaveBeenCalledOnce() - expect(mergeErrorMapSpy).toHaveBeenCalledWith( - builder['~orpc'].errorMap, - errors, - ) + expect(mergeErrorMapSpy).toHaveBeenCalledWith(builder['~orpc'].errorMap, errors) expect(applied['~orpc']).toEqual({ ...builder['~orpc'], @@ -109,10 +103,7 @@ describe('contractBuilder', () => { expect(applied).not.toBe(builder) expect(mergeErrorMapSpy).toHaveBeenCalledOnce() - expect(mergeErrorMapSpy).toHaveBeenCalledWith( - builder['~orpc'].errorMap, - errors, - ) + expect(mergeErrorMapSpy).toHaveBeenCalledWith(builder['~orpc'].errorMap, errors) expect(resolveMetaPluginsSpy).toHaveBeenCalledOnce() expect(resolveMetaPluginsSpy).toHaveBeenCalledWith( diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index 0865782465..c70f2ccbff 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -1,17 +1,21 @@ -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 { 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' + +import type { + ProcedureContractBuilderWithInput, + ProcedureContractBuilderWithOutput, +} from './builder-variants.ts' +import type { MergedErrorMap } from './error-utils.ts' +import { mergeErrorMap } from './error-utils.ts' +import type { ErrorMap } from './error.ts' +import { resolveMetaPlugins } from './meta-utils.ts' +import type { MetaPlugin } from './meta.ts' +import { getHiddenMetaPlugins } from './meta.ts' +import type { ProcedureContractDefinition } from './procedure.ts' +import { ProcedureContract } from './procedure.ts' +import type { AugmentedContractRouter } from './router-utils.ts' +import { augmentContractRouter } from './router-utils.ts' +import type { RouterContract } from './router.ts' +import type { AnySchema, Schema } from './schema.ts' /** * The input schema type used before `.input` is called. @@ -32,14 +36,18 @@ export type InitialOutputSchema = Schema * * @see {@link https://orpc.dev/docs/contract/procedure | Procedure Contract} */ -export class ContractBuilder< - TErrorMap extends ErrorMap, -> extends ProcedureContract { +export class ContractBuilder extends ProcedureContract< + InitialInputSchema, + InitialOutputSchema, + TErrorMap +> { /** * Private constructor to prevent direct instantiation. * Use the static `create` method to initialize a new instance with a safe initial definition. */ - private constructor(definition: ProcedureContractDefinition) { + private constructor( + definition: ProcedureContractDefinition, + ) { super(definition) } @@ -82,9 +90,7 @@ export class ContractBuilder< * * @see {@link https://orpc.dev/docs/contract/procedure#typesafe-errors | Procedure Contract - Typesafe Errors} */ - errors( - errors: T, - ): ContractBuilder> { + errors(errors: T): ContractBuilder> { let result = new ContractBuilder({ ...this['~orpc'], errorMap: mergeErrorMap(this['~orpc'].errorMap, errors), @@ -103,9 +109,7 @@ export class ContractBuilder< * * @see {@link https://orpc.dev/docs/contract/procedure#inputoutput-validation | Procedure Contract - Input/Output Validation} */ - input( - schema: T, - ): ProcedureContractBuilderWithInput { + input(schema: T): ProcedureContractBuilderWithInput { let result = new ContractBuilder({ ...this['~orpc'], inputSchemas: [...toArray(this['~orpc'].inputSchemas), schema], @@ -124,9 +128,7 @@ export class ContractBuilder< * * @see {@link https://orpc.dev/docs/contract/procedure#inputoutput-validation | Procedure Contract - Input/Output Validation} */ - output( - schema: T, - ): ProcedureContractBuilderWithOutput { + output(schema: T): ProcedureContractBuilderWithOutput { let result = new ContractBuilder({ ...this['~orpc'], outputSchemas: [...toArray(this['~orpc'].outputSchemas), schema], @@ -146,9 +148,7 @@ export class ContractBuilder< * * @see {@link https://orpc.dev/docs/contract/router#extending-router | Router Contract - Extending Router} */ - router( - router: T, - ): AugmentedContractRouter { + router(router: T): AugmentedContractRouter { return augmentContractRouter(router, this['~orpc']) } } diff --git a/packages/contract/src/client-factory.test-d.ts b/packages/contract/src/client-factory.test-d.ts index 0ecb8a7b94..f3183ba0a6 100644 --- a/packages/contract/src/client-factory.test-d.ts +++ b/packages/contract/src/client-factory.test-d.ts @@ -1,8 +1,9 @@ import type { ClientLink, ORPCError } from '@orpc/client' import type { PromiseWithError } from '@orpc/shared' -import { oc } from './builder' -import { createContractClientFactory } from './client-factory' -import { type } from './schema-utils' + +import { oc } from './builder.ts' +import { createContractClientFactory } from './client-factory.ts' +import { type } from './schema-utils.ts' const contract = { ping: oc, @@ -27,9 +28,7 @@ describe('createContractClientFactory', () => { const result = next() - expectTypeOf(result).toEqualTypeOf< - PromiseWithError - >() + expectTypeOf(result).toEqualTypeOf>() return result }, @@ -76,17 +75,11 @@ describe('createContractClientFactory', () => { it('returns a client & infers procedure return types', () => { const factory = createContractClientFactory(link) - expectTypeOf( - factory(contract.ping)(), - ).toEqualTypeOf< - PromiseWithError - >() + expectTypeOf(factory(contract.ping)()).toEqualTypeOf>() expectTypeOf( factory(contract.nested.pong)('string', { context: { cache: true } }), - ).toEqualTypeOf< - PromiseWithError> - >() + ).toEqualTypeOf>>() }) it('returns a router client when a router contract is passed', () => { @@ -94,15 +87,9 @@ describe('createContractClientFactory', () => { const client = factory(contract) - expectTypeOf( - client.ping(), - ).toEqualTypeOf< - PromiseWithError - >() + expectTypeOf(client.ping()).toEqualTypeOf>() - expectTypeOf( - client.nested.pong('string', { context: { cache: true } }), - ).toEqualTypeOf< + expectTypeOf(client.nested.pong('string', { context: { cache: true } })).toEqualTypeOf< PromiseWithError> >() }) @@ -111,13 +98,13 @@ describe('createContractClientFactory', () => { const factory = createContractClientFactory(link) // @ts-expect-error - invalid input - factory(contract.nested.pong)(123) + void factory(contract.nested.pong)(123) }) it('rejects invalid context', () => { const factory = createContractClientFactory(link) // @ts-expect-error - invalid context - factory(contract.nested.pong)('string', { context: { cache: 'invalid' } }) + void factory(contract.nested.pong)('string', { context: { cache: 'invalid' } }) }) }) diff --git a/packages/contract/src/client-factory.test.ts b/packages/contract/src/client-factory.test.ts index 5b0861b4f3..cada0e0a74 100644 --- a/packages/contract/src/client-factory.test.ts +++ b/packages/contract/src/client-factory.test.ts @@ -1,6 +1,7 @@ import type { ClientContext, ClientLink } from '@orpc/client' -import { createContractClientFactory } from './client-factory' -import { ProcedureContract } from './procedure' + +import { createContractClientFactory } from './client-factory.ts' +import { ProcedureContract } from './procedure.ts' beforeEach(() => { vi.clearAllMocks() @@ -53,7 +54,9 @@ describe('createContractClientFactory', () => { expect(mockedLink.call).not.toHaveBeenCalled() - expect(await (client as any)({ value: 'hello' }, { context: { requestId: 'request_1' }, signal })).toBe('__mocked__') + expect( + await (client as any)({ value: 'hello' }, { context: { requestId: 'request_1' }, signal }), + ).toBe('__mocked__') expect(mockedLink.call).toHaveBeenCalledTimes(1) expect(mockedLink.call).toHaveBeenCalledWith( @@ -121,7 +124,9 @@ describe('createContractClientFactory', () => { const client = factory(createContract(['users', 'find']) as any) as any - expect(await client({ value: 'hello' }, { context: { requestId: 'request_1' } })).toBe('__mocked__') + expect(await client({ value: 'hello' }, { context: { requestId: 'request_1' } })).toBe( + '__mocked__', + ) expect(interceptor).toHaveBeenCalledTimes(1) expect(mockedLink.call).toHaveBeenCalledTimes(1) @@ -159,7 +164,9 @@ describe('createContractClientFactory', () => { find: createContract(['users', 'find']), } as any) as any - expect(await client.find({ value: 'hello' }, { context: { requestId: 'request_1' } })).toBe('__mocked__') + expect(await client.find({ value: 'hello' }, { context: { requestId: 'request_1' } })).toBe( + '__mocked__', + ) expect(interceptor).toHaveBeenCalledTimes(1) expect(mockedLink.call).toHaveBeenCalledTimes(1) diff --git a/packages/contract/src/client-factory.ts b/packages/contract/src/client-factory.ts index a05c36635f..a9d436aba5 100644 --- a/packages/contract/src/client-factory.ts +++ b/packages/contract/src/client-factory.ts @@ -1,20 +1,20 @@ import type { ClientContext, ClientLink, ORPCClientOptions } from '@orpc/client' -import type { RouterContract } from './router' -import type { RouterContractClient } from './router-client' import { createORPCClient } from '@orpc/client' import { get, isTypescriptObject, set } from '@orpc/shared' -import { resolveBasePathMeta } from './meta-built-in' -import { ProcedureContract } from './procedure' -export interface ContractClientFactory< - TClientContext extends ClientContext, -> { +import { resolveBasePathMeta } from './meta-built-in.ts' +import { ProcedureContract } from './procedure.ts' +import type { RouterContractClient } from './router-client.ts' +import type { RouterContract } from './router.ts' + +export interface ContractClientFactory { (contract: T): RouterContractClient } -export interface ContractClientFactoryOptions< - TClientContext extends ClientContext, -> extends Pick>, 'interceptors' | 'scoped'> { +export interface ContractClientFactoryOptions extends Pick< + ORPCClientOptions>, + 'interceptors' | 'scoped' +> { /** * An optional reference to the root router-contract. * When provided, the client factory will automatically register the passed contract @@ -32,9 +32,7 @@ export interface ContractClientFactoryOptions< * * @see {@link https://orpc.dev/docs/contract/client-factory | Contract Client Factory} */ -export function createContractClientFactory< - TClientContext extends ClientContext, ->( +export function createContractClientFactory( link: ClientLink, options: ContractClientFactoryOptions = {}, ): ContractClientFactory { @@ -65,7 +63,7 @@ export function createContractClientFactory< register(contract, path) } - return createORPCClient(link, { ...options as any, scoped: get(options.scoped, path), path }) + return createORPCClient(link, { ...(options as any), scoped: get(options.scoped, path), path }) } return factory diff --git a/packages/contract/src/error-factory.test-d.ts b/packages/contract/src/error-factory.test-d.ts index 807ed21a9a..f5060debb7 100644 --- a/packages/contract/src/error-factory.test-d.ts +++ b/packages/contract/src/error-factory.test-d.ts @@ -1,9 +1,10 @@ import type { ORPCError } from '@orpc/client' -import type { ErrorMap, ORPCErrorFromErrorMap } from './error' -import type { ORPCErrorConstructorMap, ORPCErrorFactory } from './error-factory' -import type { Schema } from './schema' -import z from 'zod' -import { error } from './error-factory' +import * as z from 'zod' + +import type { ORPCErrorConstructorMap, ORPCErrorFactory } from './error-factory.ts' +import { error } from './error-factory.ts' +import type { ErrorMap, ORPCErrorFromErrorMap } from './error.ts' +import type { Schema } from './schema.ts' describe('error factory', () => { const TestError = error('TEST', { @@ -20,11 +21,15 @@ describe('error factory', () => { it('infers the factory type from the options', () => { expectTypeOf(TestError).toEqualTypeOf>() expectTypeOf(SimpleError).toEqualTypeOf>() - expectTypeOf(OptionalError).toEqualTypeOf>() + expectTypeOf(OptionalError).toEqualTypeOf< + ORPCErrorFactory<'OPTIONAL', { value: number } | undefined> + >() }) it('creates a class that constructs typed ORPCError instances', () => { - expectTypeOf(new TestError({ data: { value: 1 } })).toEqualTypeOf>() + expectTypeOf(new TestError({ data: { value: 1 } })).toEqualTypeOf< + ORPCError<'TEST', { value: number }> + >() expectTypeOf(new SimpleError()).toEqualTypeOf>() // @ts-expect-error - data is required @@ -38,8 +43,12 @@ describe('error factory', () => { }) it('options can be omitted when the data schema allows undefined', () => { - expectTypeOf(new OptionalError()).toEqualTypeOf>() - expectTypeOf(new OptionalError({ data: { value: 1 } })).toEqualTypeOf>() + expectTypeOf(new OptionalError()).toEqualTypeOf< + ORPCError<'OPTIONAL', { value: number } | undefined> + >() + expectTypeOf(new OptionalError({ data: { value: 1 } })).toEqualTypeOf< + ORPCError<'OPTIONAL', { value: number } | undefined> + >() }) it('exposes typed static code, message, and data', () => { @@ -65,11 +74,8 @@ describe('error factory', () => { [SimpleError.code]: SimpleError, } satisfies ErrorMap - expectTypeOf< - ORPCErrorFromErrorMap - >().toEqualTypeOf< - | ORPCError<'TEST', { value: number }> - | ORPCError<'SIMPLE', unknown> + expectTypeOf>().toEqualTypeOf< + ORPCError<'TEST', { value: number }> | ORPCError<'SIMPLE', unknown> >() }) }) @@ -87,8 +93,12 @@ describe('ORPCErrorConstructorMap', () => { const constructors = {} as ORPCErrorConstructorMap it('constructs typed ORPCError instances', () => { - expectTypeOf(constructors.BASE({ data: { output: 123 } })).toEqualTypeOf>() - expectTypeOf(constructors.BASE({ data: { output: 123 }, message: 'custom', cause: 'cause' })).toEqualTypeOf>() + expectTypeOf(constructors.BASE({ data: { output: 123 } })).toEqualTypeOf< + ORPCError<'BASE', { output: number }> + >() + expectTypeOf( + constructors.BASE({ data: { output: 123 }, message: 'custom', cause: 'cause' }), + ).toEqualTypeOf>() // @ts-expect-error - invalid data constructors.BASE({ data: { output: '123' } }) @@ -99,8 +109,12 @@ describe('ORPCErrorConstructorMap', () => { }) it('options can be omitted when the data schema allows undefined', () => { - expectTypeOf(constructors.OVERRIDE()).toEqualTypeOf>() - expectTypeOf(constructors.OVERRIDE({ data: { output: 123 } })).toEqualTypeOf>() + expectTypeOf(constructors.OVERRIDE()).toEqualTypeOf< + ORPCError<'OVERRIDE', { output: number } | undefined> + >() + expectTypeOf(constructors.OVERRIDE({ data: { output: 123 } })).toEqualTypeOf< + ORPCError<'OVERRIDE', { output: number } | undefined> + >() }) it('uses the map key as the error code for error factory items', () => { @@ -114,6 +128,8 @@ describe('ORPCErrorConstructorMap', () => { const factoryConstructors = {} as ORPCErrorConstructorMap - expectTypeOf(factoryConstructors.FACTORY({ data: { output: 123 } })).toEqualTypeOf>() + expectTypeOf(factoryConstructors.FACTORY({ data: { output: 123 } })).toEqualTypeOf< + ORPCError<'FACTORY', { output: number }> + >() }) }) diff --git a/packages/contract/src/error-factory.test.ts b/packages/contract/src/error-factory.test.ts index eef5149bd6..ab42f78cc3 100644 --- a/packages/contract/src/error-factory.test.ts +++ b/packages/contract/src/error-factory.test.ts @@ -1,8 +1,9 @@ -import type { Schema } from './schema' import { ORPCError } from '@orpc/client' -import z from 'zod' -import { ValidationError } from './error' -import { createORPCErrorConstructorMap, error } from './error-factory' +import * as z from 'zod' + +import { createORPCErrorConstructorMap, error } from './error-factory.ts' +import { ValidationError } from './error.ts' +import type { Schema } from './schema.ts' describe('error factory', () => { const dataSchema = z.object({ value: z.number() }) @@ -71,7 +72,7 @@ describe('error factory', () => { '~standard': { version: 1, vendor: 'test', - validate: async value => ({ value }), + validate: async (value) => ({ value }), }, } satisfies Schema, }) @@ -91,7 +92,7 @@ describe('error factory', () => { }) it('defaults static data to a passthrough schema', async () => { - const result = await SimpleError.data['~standard'].validate('anything') as any + const result = (await SimpleError.data['~standard'].validate('anything')) as any expect(result.value).toBe('anything') expect(result.issues).toBeUndefined() @@ -140,7 +141,7 @@ describe('error factory', () => { '~standard': { version: 1, vendor: 'test', - validate: async value => ({ value }), + validate: async (value) => ({ value }), }, } satisfies Schema, }) diff --git a/packages/contract/src/error-factory.ts b/packages/contract/src/error-factory.ts index 09c962f176..d9e12d3adf 100644 --- a/packages/contract/src/error-factory.ts +++ b/packages/contract/src/error-factory.ts @@ -1,12 +1,12 @@ import type { ORPCErrorCode, ORPCErrorOptions } from '@orpc/client' -import type { MaybeOptionalOptions, Writable } from '@orpc/shared' -import type { ErrorMap, ErrorMapItem } from './error' -import type { AnySchema, InferSchemaInput, Schema } from './schema' - import { ORPCError, RECURSIVE_CLIENT_UNWRAP_KEYS } from '@orpc/client' +import type { MaybeOptionalOptions, Writable } from '@orpc/shared' import { getOwn, resolveMaybeOptionalOptions } from '@orpc/shared' -import { ValidationError } from './error' -import { type } from './schema-utils' + +import type { ErrorMap, ErrorMapItem } from './error.ts' +import { ValidationError } from './error.ts' +import { type } from './schema-utils.ts' +import type { AnySchema, InferSchemaInput, Schema } from './schema.ts' export interface ORPCErrorFactoryOptions { /** @@ -63,9 +63,7 @@ export function error( const result = schema['~standard'].validate(value) if (result instanceof Promise) { - throw new TypeError( - `Error factory "${code}" does not support async data schemas.`, - ) + throw new TypeError(`Error factory "${code}" does not support async data schemas.`) } return result @@ -98,7 +96,7 @@ export function error( super(code, { ...options, message: options.message ?? message, data }) } - static override[Symbol.hasInstance](instance: unknown): boolean { + static override [Symbol.hasInstance](instance: unknown): boolean { if (!(instance instanceof ORPCError)) { return false } @@ -119,15 +117,17 @@ export function error( export type ORPCErrorConstructorMapItemOptions = Omit, 'status'> export interface ORPCErrorConstructorMapItem { - (...rest: MaybeOptionalOptions>): ORPCError + ( + ...rest: MaybeOptionalOptions> + ): ORPCError } export type ORPCErrorConstructorMap = { [K in keyof T]: T[K] extends ErrorMapItem ? ORPCErrorConstructorMapItem< - K & ORPCErrorCode, - T[K]['data'] extends AnySchema ? InferSchemaInput : unknown - > + K & ORPCErrorCode, + T[K]['data'] extends AnySchema ? InferSchemaInput : unknown + > : never } @@ -153,7 +153,9 @@ export type ORPCErrorConstructorMap = { * throw errorMap.NOT_FOUND() * ``` */ -export function createORPCErrorConstructorMap(errorMap: T): ORPCErrorConstructorMap { +export function createORPCErrorConstructorMap( + errorMap: T, +): ORPCErrorConstructorMap { const proxy = new Proxy(errorMap, { get(target, code) { if (typeof code !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(code)) { diff --git a/packages/contract/src/error-utils.test-d.ts b/packages/contract/src/error-utils.test-d.ts index 72b5d5692e..20c5855ee8 100644 --- a/packages/contract/src/error-utils.test-d.ts +++ b/packages/contract/src/error-utils.test-d.ts @@ -1,11 +1,14 @@ -/* eslint-disable ts/no-empty-object-type */ - -import type { MergedErrorMap } from './error-utils' import { expectTypeOf, it } from 'vitest' +import type { MergedErrorMap } from './error-utils.ts' + it('MergedErrorMap', () => { - expectTypeOf>().toEqualTypeOf<{ BAD_REQUEST: {} } & { NOT_FOUND: {}, INTERNAL_SERVER_ERROR: {} }>() - expectTypeOf>().toEqualTypeOf<{ BAD_REQUEST: {} }>() + expectTypeOf< + MergedErrorMap<{ BAD_REQUEST: {} }, { NOT_FOUND: {}; INTERNAL_SERVER_ERROR: {} }> + >().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 index 81277add00..91c442d260 100644 --- a/packages/contract/src/error-utils.test.ts +++ b/packages/contract/src/error-utils.test.ts @@ -1,7 +1,8 @@ -import type { ErrorMap } from './error' import * as ClientModule from '@orpc/client' -import z from 'zod' -import { mergeErrorMap, reconcileORPCError } from './error-utils' +import * as z from 'zod' + +import { mergeErrorMap, reconcileORPCError } from './error-utils.ts' +import type { ErrorMap } from './error.ts' const ORPCError = ClientModule.ORPCError const cloneORPCErrorSpy = vi.spyOn(ClientModule, 'cloneORPCError') @@ -31,7 +32,7 @@ it('mergeErrorMap', () => { describe('reconcileORPCError', () => { describe('no map error matched', () => { - const map: ErrorMap = { } + const map: ErrorMap = {} it('should return error itself if error is not defined', async () => { const error = new ORPCError('CODE', { message: 'm', data: 'd' }) diff --git a/packages/contract/src/error-utils.ts b/packages/contract/src/error-utils.ts index c50d1d68a0..005d6492dc 100644 --- a/packages/contract/src/error-utils.ts +++ b/packages/contract/src/error-utils.ts @@ -1,15 +1,20 @@ import type { AnyORPCError } from '@orpc/client' -import type { Writable } from '@orpc/shared' -import type { ErrorMap } from './error' import { cloneORPCError } from '@orpc/client' +import type { Writable } from '@orpc/shared' import { getOwn } from '@orpc/shared' -export type MergedErrorMap - = keyof T1 extends never | keyof T2 - ? T2 - : Omit & T2 +import type { ErrorMap } from './error.ts' + +export type MergedErrorMap = keyof T1 extends + // oxlint-disable-next-line typescript/no-redundant-type-constituents + never | keyof T2 + ? T2 + : Omit & T2 -export function mergeErrorMap(errorMap1: T1 | undefined, errorMap2: T2 | undefined): MergedErrorMap { +export function mergeErrorMap( + errorMap1: T1 | undefined, + errorMap2: T2 | undefined, +): MergedErrorMap { return { ...errorMap1, ...errorMap2 } as any } diff --git a/packages/contract/src/error.test-d.ts b/packages/contract/src/error.test-d.ts index fe15346ef6..f6602ac154 100644 --- a/packages/contract/src/error.test-d.ts +++ b/packages/contract/src/error.test-d.ts @@ -1,6 +1,7 @@ import type { ORPCError } from '@orpc/client' -import type { ORPCErrorFromErrorMap } from './error' -import z from 'zod' +import * as z from 'zod' + +import type { ORPCErrorFromErrorMap } from './error.ts' describe('ORPCErrorFromErrorMap', () => { it('converts an error map to an ORPCError union and defaults to unknown when schema is undefined', () => { @@ -10,9 +11,7 @@ describe('ORPCErrorFromErrorMap', () => { UNDEFINED_SCHEMA: {}, } - expectTypeOf< - ORPCErrorFromErrorMap - >().toEqualTypeOf< + expectTypeOf>().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 8bf046d742..e04d5a9cb2 100644 --- a/packages/contract/src/error.test.ts +++ b/packages/contract/src/error.test.ts @@ -1,4 +1,4 @@ -import { ValidationError } from './error' +import { ValidationError } from './error.ts' it('validationError', () => { const issues = [{ path: ['a'], message: 'invalid' }] as any diff --git a/packages/contract/src/error.ts b/packages/contract/src/error.ts index fcdaac46d4..6fc473f8a9 100644 --- a/packages/contract/src/error.ts +++ b/packages/contract/src/error.ts @@ -1,5 +1,6 @@ import type { ORPCError, ORPCErrorCode } from '@orpc/client' -import type { AnySchema, InferSchemaOutput, SchemaIssue } from './schema' + +import type { AnySchema, InferSchemaOutput, SchemaIssue } from './schema.ts' export interface ErrorMapItem { /** @@ -26,9 +27,9 @@ export type ErrorMap = { export type ORPCErrorFromErrorMap = { [K in keyof TErrorMap]: TErrorMap[K] extends ErrorMapItem ? ORPCError< - K & ORPCErrorCode, - TErrorMap[K]['data'] extends AnySchema ? InferSchemaOutput : unknown - > + K & ORPCErrorCode, + TErrorMap[K]['data'] extends AnySchema ? InferSchemaOutput : unknown + > : never }[keyof TErrorMap] diff --git a/packages/contract/src/index.test.ts b/packages/contract/src/index.test.ts index 1a98e575e8..12cdbe15d3 100644 --- a/packages/contract/src/index.test.ts +++ b/packages/contract/src/index.test.ts @@ -1,5 +1,5 @@ it('exports oc', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ oc: expect.any(Object), }) }) diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 30be2534e8..7eb5d6e867 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -1,21 +1,21 @@ -export * from './builder' -export * from './builder-variants' -export * from './client-factory' -export * from './error' -export * from './error-factory' -export * from './error-utils' -export * from './meta' -export * from './meta-built-in' -export * from './meta-utils' -export * from './procedure' +export * from './builder.ts' +export * from './builder-variants.ts' +export * from './client-factory.ts' +export * from './error.ts' +export * from './error-factory.ts' +export * from './error-utils.ts' +export * from './meta.ts' +export * from './meta-built-in.ts' +export * from './meta-utils.ts' +export * from './procedure.ts' export type { /** * @deprecated Use `AnyProcedureContract` instead. */ AnyProcedureContract as AnyContractProcedure, -} from './procedure' -export * from './procedure-client' -export * from './router' +} from './procedure.ts' +export * from './procedure-client.ts' +export * from './router.ts' export type { /** * @deprecated Use `RouterContract` instead. @@ -33,23 +33,20 @@ export type { * @deprecated Use `InferRouterContractOutputs` instead. */ InferRouterContractOutputs as InferContractRouterOutputs, -} from './router' -export * from './router-client' +} from './router.ts' +export * from './router-client.ts' export type { /** * @deprecated Use `RouterContractClient` instead. */ RouterContractClient as ContractRouterClient, -} from './router-client' +} from './router-client.ts' export { getProcedureContractOrThrow, getRouterContract, minifyRouterContract, -} from './router-utils' -export type { - AugmentContractRouterOptions, - AugmentedContractRouter, -} from './router-utils' +} from './router-utils.ts' +export type { AugmentContractRouterOptions, AugmentedContractRouter } from './router-utils.ts' export { /** * @deprecated Use `getRouterContract` instead. @@ -59,20 +56,17 @@ export { * @deprecated Use `minifyRouterContract` instead. */ minifyRouterContract as minifyContractRouter, -} from './router-utils' -export * from './schema' -export { - asyncIteratorObject, - getAsyncIteratorObjectSchemasDetails, -} from './schema-built-in' -export type { AsyncIteratorObjectSchemasDetails } from './schema-built-in' +} from './router-utils.ts' +export * from './schema.ts' +export { asyncIteratorObject, getAsyncIteratorObjectSchemasDetails } from './schema-built-in.ts' +export type { AsyncIteratorObjectSchemasDetails } from './schema-built-in.ts' export { /** * @deprecated Use `asyncIteratorObject` instead. */ asyncIteratorObject as eventIterator, -} from './schema-built-in' -export * from './schema-utils' +} from './schema-built-in.ts' +export * from './schema-utils.ts' export type { Client, @@ -82,9 +76,4 @@ export type { FriendlyClientOptions, } from '@orpc/client' -export type { - AsyncIteratorClass, - PromiseWithError, - Registry, - ThrowableError, -} from '@orpc/shared' +export type { AsyncIteratorClass, PromiseWithError, Registry, ThrowableError } from '@orpc/shared' diff --git a/packages/contract/src/meta-built-in.test.ts b/packages/contract/src/meta-built-in.test.ts index 1a9096f8fa..5d206b1a35 100644 --- a/packages/contract/src/meta-built-in.test.ts +++ b/packages/contract/src/meta-built-in.test.ts @@ -1,5 +1,5 @@ -import { getPathMeta, meta, resolveBasePathMeta } from './meta-built-in' -import { ProcedureContract } from './procedure' +import { getPathMeta, meta, resolveBasePathMeta } from './meta-built-in.ts' +import { ProcedureContract } from './procedure.ts' describe('meta.path', () => { it('returns plugin with correct name', () => { @@ -70,11 +70,15 @@ describe('resolveBasePathMeta', () => { }) it('returns an empty base path when meta.path matches the position exactly', () => { - expect(resolveBasePathMeta({ users: { list: createContract(['users', 'list']) } } as any)).toEqual([]) + expect( + resolveBasePathMeta({ users: { list: createContract(['users', 'list']) } } as any), + ).toEqual([]) }) it('throws when meta.path does not match the procedure position', () => { - expect(() => resolveBasePathMeta({ users: { find: createContract(['members', 'find']) } } as any)).toThrow( + expect(() => + resolveBasePathMeta({ users: { find: createContract(['members', 'find']) } } as any), + ).toThrow( 'Procedure contract at "users.find" defines meta.path "members.find" that does not match its path inside the given router contract.', ) }) @@ -82,9 +86,11 @@ describe('resolveBasePathMeta', () => { it('ignore invalid contract', () => { expect(resolveBasePathMeta({ find: 'invalid' } as any)).toBeUndefined() - expect(resolveBasePathMeta({ - find: 'invalid', - create: createContract(['users', 'create']), - } as any)).toEqual(['users']) + expect( + resolveBasePathMeta({ + find: 'invalid', + create: createContract(['users', 'create']), + } as any), + ).toEqual(['users']) }) }) diff --git a/packages/contract/src/meta-built-in.ts b/packages/contract/src/meta-built-in.ts index dbd4160b2b..7a2464d042 100644 --- a/packages/contract/src/meta-built-in.ts +++ b/packages/contract/src/meta-built-in.ts @@ -1,9 +1,10 @@ -import type { ErrorMap } from './error' -import type { Meta, MetaPlugin } from './meta' -import type { RouterContract } from './router' -import type { AnySchema } from './schema' import { isTypescriptObject } from '@orpc/shared' -import { ProcedureContract } from './procedure' + +import type { ErrorMap } from './error.ts' +import type { Meta, MetaPlugin } from './meta.ts' +import { ProcedureContract } from './procedure.ts' +import type { RouterContract } from './router.ts' +import type { AnySchema } from './schema.ts' export interface PathMetaPlugin< TInputSchema extends AnySchema, @@ -21,11 +22,7 @@ export interface PathMetaPlugin< * @see {@link https://orpc.dev/docs/contract/client-factory | Contract Client Factory} */ export const meta = { - path< - TInputSchema extends AnySchema, - TOutputSchema extends AnySchema, - TErrorMap extends ErrorMap, - >( + path( path: string[], ): PathMetaPlugin { return { @@ -51,7 +48,10 @@ export function getPathMeta(procedureOrLazy: { '~orpc': { meta: Meta } }): strin * * Returns `undefined` when no procedure-contract defines `meta.path`. */ -export function resolveBasePathMeta(contract: RouterContract, currentPath: string[] = []): string[] | undefined { +export function resolveBasePathMeta( + contract: RouterContract, + currentPath: string[] = [], +): string[] | undefined { if (contract instanceof ProcedureContract) { const path = getPathMeta(contract) diff --git a/packages/contract/src/meta-utils.test.ts b/packages/contract/src/meta-utils.test.ts index c4db6b707d..a02e6536bb 100644 --- a/packages/contract/src/meta-utils.test.ts +++ b/packages/contract/src/meta-utils.test.ts @@ -1,24 +1,24 @@ -import type { AnyMetaPlugin } from './meta' -import { oc } from './builder' -import { defineMeta, resolveMetaPlugins } from './meta-utils' +import { oc } from './builder.ts' +import { defineMeta, resolveMetaPlugins } from './meta-utils.ts' +import type { AnyMetaPlugin } from './meta.ts' it('resolveMetaPlugins', () => { const baseMeta = { mode: 'base' } const plugin1 = { name: 'plugin1', - init: vi.fn(m => ({ ...m, p1: true })), - apply: vi.fn(m => ({ ...m, a1: true })), + 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 })), + 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 })), + init: vi.fn((m) => ({ ...m, p3: true })), + apply: vi.fn((m) => ({ ...m, a3: true })), } satisfies AnyMetaPlugin const plugin4 = { name: 'plugin4', @@ -51,7 +51,13 @@ it('resolveMetaPlugins', () => { 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(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) @@ -64,14 +70,16 @@ it('defineMeta', () => { scope?: 'user' | 'admin' } - const [authMeta, getAuthMeta] = defineMeta( - 'auth', - (incoming: AuthMeta, current) => ({ ...current, ...incoming }), - ) + 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' })) + const requiredAndAdminProcedure = oc + .meta(authMeta({ required: true, scope: 'user' })) + .meta(authMeta({ scope: 'admin' })) expect(getAuthMeta(oc)).toBeUndefined() expect(getAuthMeta(requiredAuthProcedure)).toEqual({ required: true }) diff --git a/packages/contract/src/meta-utils.ts b/packages/contract/src/meta-utils.ts index 808c8af979..36059cac02 100644 --- a/packages/contract/src/meta-utils.ts +++ b/packages/contract/src/meta-utils.ts @@ -1,8 +1,9 @@ -import type { ErrorMap } from './error' -import type { AnyMetaPlugin, Meta, MetaPlugin } from './meta' -import type { AnySchema } from './schema' import { toArray } from '@orpc/shared' +import type { ErrorMap } from './error.ts' +import type { AnyMetaPlugin, Meta, MetaPlugin } from './meta.ts' +import type { AnySchema } from './schema.ts' + export function resolveMetaPlugins< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, @@ -96,7 +97,8 @@ export function defineMeta( }, }) - const getMeta = (procedureOrLazy: { '~orpc': { meta: Meta } }) => procedureOrLazy['~orpc'].meta[name] as TData | undefined + 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 208c22ec9a..92d40b1615 100644 --- a/packages/contract/src/meta.test.ts +++ b/packages/contract/src/meta.test.ts @@ -1,11 +1,11 @@ -import type { AnyMetaPlugin } from './meta' -import { getHiddenMetaPlugins, setHiddenMetaPlugins } from './meta' +import type { AnyMetaPlugin } from './meta.ts' +import { getHiddenMetaPlugins, setHiddenMetaPlugins } from './meta.ts' describe('getHiddenMetaPlugins', () => { const metaPlugins: AnyMetaPlugin[] = [ { name: 'plugin', - init: meta => ({ ...meta, enabled: true }), + init: (meta) => ({ ...meta, enabled: true }), }, ] diff --git a/packages/contract/src/meta.ts b/packages/contract/src/meta.ts index 889dfb4080..561a47da2a 100644 --- a/packages/contract/src/meta.ts +++ b/packages/contract/src/meta.ts @@ -1,7 +1,8 @@ -import type { ErrorMap } from './error' -import type { AnySchema } from './schema' import { isTypescriptObject } from '@orpc/shared' +import type { ErrorMap } from './error.ts' +import type { AnySchema } from './schema.ts' + /** * Arbitrary metadata attached to a procedure. * Middleware, plugins, and tooling can read it later to control behavior. @@ -70,6 +71,10 @@ export function getHiddenMetaPlugins(container: unknown): AnyMetaPlugin[] | unde return container[HIDDEN_META_PLUGINS_SYMBOL] as AnyMetaPlugin[] | undefined } -export function setHiddenMetaPlugins(container: T, metaPlugins: AnyMetaPlugin[]): void { - (container as any)[HIDDEN_META_PLUGINS_SYMBOL] = metaPlugins +// oxlint-disable-next-line typescript/no-unnecessary-type-parameters +export function setHiddenMetaPlugins( + container: T, + metaPlugins: AnyMetaPlugin[], +): void { + ;(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 639bce4fd1..a9a42ec028 100644 --- a/packages/contract/src/plugins/index.test.ts +++ b/packages/contract/src/plugins/index.test.ts @@ -1,5 +1,5 @@ it('exports RequestValidationLinkPlugin, ResponseValidationLinkPlugin', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ RequestValidationLinkPlugin: expect.any(Function), ResponseValidationLinkPlugin: expect.any(Function), }) diff --git a/packages/contract/src/plugins/index.ts b/packages/contract/src/plugins/index.ts index 72792ca106..df6c53284d 100644 --- a/packages/contract/src/plugins/index.ts +++ b/packages/contract/src/plugins/index.ts @@ -1,14 +1,14 @@ -export * from './request-validation' +export * from './request-validation.ts' export { /** * @deprecated Use `RequestValidationLinkPlugin` instead. */ RequestValidationLinkPlugin as RequestValidationPlugin, -} from './request-validation' -export * from './response-validation' +} from './request-validation.ts' +export * from './response-validation.ts' export { /** * @deprecated Use `ResponseValidationLinkPlugin` instead. */ ResponseValidationLinkPlugin as ResponseValidationPlugin, -} from './response-validation' +} from './response-validation.ts' diff --git a/packages/contract/src/plugins/request-validation.test.ts b/packages/contract/src/plugins/request-validation.test.ts index a0c0115cec..4ed600335a 100644 --- a/packages/contract/src/plugins/request-validation.test.ts +++ b/packages/contract/src/plugins/request-validation.test.ts @@ -1,9 +1,10 @@ import { ORPCError } from '@orpc/client' import { StandardLink } from '@orpc/client/standard' import * as z from 'zod' -import { ValidationError } from '../error' -import { ProcedureContract } from '../procedure' -import { RequestValidationLinkPlugin } from './request-validation' + +import { ValidationError } from '../error.ts' +import { ProcedureContract } from '../procedure.ts' +import { RequestValidationLinkPlugin } from './request-validation.ts' beforeEach(() => { vi.clearAllMocks() @@ -11,10 +12,7 @@ beforeEach(() => { describe('requestValidationLinkPlugin', () => { const chainedProcedure = new ProcedureContract({ - inputSchemas: [ - z.number().transform(value => value + 1), - z.number().min(2), - ], + inputSchemas: [z.number().transform((value) => value + 1), z.number().min(2)], outputSchemas: [], errorMap: {}, meta: {}, @@ -22,7 +20,7 @@ describe('requestValidationLinkPlugin', () => { const stackedObjectProcedure = new ProcedureContract({ inputSchemas: [ - z.object({ parent: z.string().transform(value => `parent__${value}`) }), + z.object({ parent: z.string().transform((value) => `parent__${value}`) }), z.object({ parent: z.string(), child: z.string() }), ], outputSchemas: [], @@ -68,16 +66,12 @@ describe('requestValidationLinkPlugin', () => { const interceptor = vi.fn(({ next }) => next()) const link = new StandardLink(codec, transport, { - plugins: [ - new RequestValidationLinkPlugin(contract), - ], + plugins: [new RequestValidationLinkPlugin(contract)], interceptors: [interceptor], }) const linkUsingValidatedInput = new StandardLink(codec, transport, { - plugins: [ - new RequestValidationLinkPlugin(contract, { forwardValidatedInput: true }), - ], + plugins: [new RequestValidationLinkPlugin(contract, { forwardValidatedInput: true })], }) it('forwards the original input by default after local validation succeeds', async () => { @@ -136,7 +130,9 @@ describe('requestValidationLinkPlugin', () => { codec.decodeResponse.mockResolvedValueOnce({ kind: 'output', output: '__output__' }) const input = { parent: 'PARENT', child: 'CHILD', unknown: 'UNKNOWN' } - const output = await linkUsingValidatedInput.call(['stackedObjectProcedure'], input as any, { context: {} }) + const output = await linkUsingValidatedInput.call(['stackedObjectProcedure'], input as any, { + context: {}, + }) expect(output).toBe('__output__') expect(codec.encodeInput).toHaveBeenCalledWith( @@ -161,7 +157,9 @@ describe('requestValidationLinkPlugin', () => { codec.decodeResponse.mockResolvedValueOnce({ kind: 'output', output: '__output__' }) const input = { params: { id: 'ID', slug: 'SLUG', unknown: 'UNKNOWN' }, query: { page: 1 } } - const output = await linkUsingValidatedInput.call(['nestedObjectProcedure'], input as any, { context: {} }) + const output = await linkUsingValidatedInput.call(['nestedObjectProcedure'], input as any, { + context: {}, + }) expect(output).toBe('__output__') expect(codec.encodeInput).toHaveBeenCalledWith( @@ -188,7 +186,9 @@ describe('requestValidationLinkPlugin', () => { const output = await link.call(['withoutInputSchema'], 'anything', { context: {} }) expect(output).toBe('__output__') - expect(codec.encodeInput).toHaveBeenCalledWith('anything', ['withoutInputSchema'], { context: {} }) + expect(codec.encodeInput).toHaveBeenCalledWith('anything', ['withoutInputSchema'], { + context: {}, + }) }) it('throws a BAD_REQUEST error when any validation step fails', async () => { diff --git a/packages/contract/src/plugins/request-validation.ts b/packages/contract/src/plugins/request-validation.ts index 30516335b6..0cc11c5d34 100644 --- a/packages/contract/src/plugins/request-validation.ts +++ b/packages/contract/src/plugins/request-validation.ts @@ -1,10 +1,11 @@ import type { ClientContext } from '@orpc/client' -import type { StandardLinkOptions, StandardLinkPlugin } from '@orpc/client/standard' -import type { RouterContract } from '../router' import { ORPCError } from '@orpc/client' +import type { StandardLinkOptions, StandardLinkPlugin } from '@orpc/client/standard' import { mergeTwoLevels, toArray } from '@orpc/shared' -import { ValidationError } from '../error' -import { getProcedureContractOrThrow } from '../router-utils' + +import { ValidationError } from '../error.ts' +import { getProcedureContractOrThrow } from '../router-utils.ts' +import type { RouterContract } from '../router.ts' export interface RequestValidationLinkPluginOptions<_T extends ClientContext> { /** @@ -40,39 +41,43 @@ export class RequestValidationLinkPlugin implements Sta init(options: StandardLinkOptions): StandardLinkOptions { return { ...options, - interceptors: [...toArray(options.interceptors), async ({ next, ...interceptorOptions }) => { - const procedure = getProcedureContractOrThrow(this.contract, interceptorOptions.path) + interceptors: [ + ...toArray(options.interceptors), + async ({ next, ...interceptorOptions }) => { + const procedure = getProcedureContractOrThrow(this.contract, interceptorOptions.path) - const inputSchemas = toArray(procedure['~orpc'].inputSchemas) - const originalInput = interceptorOptions.input - let currentInput = originalInput + const inputSchemas = toArray(procedure['~orpc'].inputSchemas) + const originalInput = interceptorOptions.input + let currentInput = originalInput - for (const [index, schema] of inputSchemas.entries()) { - // Mirrors the server's stacked input validation. - const validating = index !== 0 ? mergeTwoLevels(originalInput, currentInput) : currentInput - const result = await schema['~standard'].validate(validating) + for (const [index, schema] of inputSchemas.entries()) { + // Mirrors the server's stacked input validation. + const validating = + index !== 0 ? mergeTwoLevels(originalInput, currentInput) : currentInput + const result = await schema['~standard'].validate(validating) - if (result.issues) { - throw new ORPCError('BAD_REQUEST', { - message: 'Input validation failed', - data: { - issues: result.issues, - }, - cause: new ValidationError({ + if (result.issues) { + throw new ORPCError('BAD_REQUEST', { message: 'Input validation failed', - issues: result.issues, - invalidData: validating, - }), - }) - } + data: { + issues: result.issues, + }, + cause: new ValidationError({ + message: 'Input validation failed', + issues: result.issues, + invalidData: validating, + }), + }) + } - currentInput = index !== 0 ? mergeTwoLevels(currentInput, result.value) : result.value - } + currentInput = index !== 0 ? mergeTwoLevels(currentInput, result.value) : result.value + } - return this.forwardValidatedInput - ? next({ ...interceptorOptions, input: currentInput }) - : 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 1ce12ffe66..1fd87d4498 100644 --- a/packages/contract/src/plugins/response-validation.test.ts +++ b/packages/contract/src/plugins/response-validation.test.ts @@ -1,14 +1,15 @@ import { getEventMeta, ORPCError, withEventMeta } from '@orpc/client' import { StandardLink } from '@orpc/client/standard' import * as z from 'zod' -import { ValidationError } from '../error' -import { reconcileORPCError } from '../error-utils' -import { ProcedureContract } from '../procedure' -import { asyncIteratorObject } from '../schema-built-in' -import { ResponseValidationLinkPlugin } from './response-validation' - -vi.mock('../error-utils', async original => ({ - ...await original(), + +import { reconcileORPCError } from '../error-utils.ts' +import { ValidationError } from '../error.ts' +import { ProcedureContract } from '../procedure.ts' +import { asyncIteratorObject } from '../schema-built-in.ts' +import { ResponseValidationLinkPlugin } from './response-validation.ts' + +vi.mock('../error-utils.ts', async (original) => ({ + ...(await original()), reconcileORPCError: vi.fn(), })) @@ -20,16 +21,16 @@ describe('responseValidationLinkPlugin', () => { const procedure = new ProcedureContract({ outputSchemas: [ z.object({ - value: z.number().transform(value => value + 1), + value: z.number().transform((value) => value + 1), }), z.object({ - value: z.string().transform(value => Number.parseInt(value)), + value: z.string().transform((value) => Number.parseInt(value)), }), ], errorMap: { TEST: { data: z.object({ - value: z.string().transform(value => Number.parseInt(value)), + value: z.string().transform((value) => Number.parseInt(value)), }), }, }, @@ -68,15 +69,22 @@ describe('responseValidationLinkPlugin', () => { const interceptor = vi.fn(({ next }) => next()) const link = new StandardLink(codec, transport, { - plugins: [ - new ResponseValidationLinkPlugin(contract), - ], + plugins: [new ResponseValidationLinkPlugin(contract)], interceptors: [interceptor], }) function mockResponse(url: string, decoded: unknown, status = 200) { - codec.encodeInput.mockResolvedValueOnce({ method: 'POST', url, headers: {}, body: '__encoded__' }) - transport.send.mockResolvedValueOnce({ status, headers: {}, resolveBody: () => Promise.resolve('__body__') }) + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url, + headers: {}, + body: '__encoded__', + }) + transport.send.mockResolvedValueOnce({ + status, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), + }) codec.decodeResponse.mockResolvedValueOnce(decoded) } @@ -131,14 +139,26 @@ describe('responseValidationLinkPlugin', () => { await expect(link.call(['nested', 'procedure'], {}, { context: {} })).rejects.toBe(reconciled) await expect(interceptor.mock.results[0]?.value).rejects.toBe(error) - expect(reconcileORPCError).toHaveBeenCalledWith(contract.nested.procedure['~orpc'].errorMap, error) + expect(reconcileORPCError).toHaveBeenCalledWith( + contract.nested.procedure['~orpc'].errorMap, + error, + ) }) it('rethrows non-ORPCError failures without reconciliation', async () => { const error = new Error('plain failure') - codec.encodeInput.mockResolvedValueOnce({ method: 'POST', url: '/nested/procedure', headers: {}, body: '__encoded__' }) - transport.send.mockResolvedValueOnce({ status: 500, headers: {}, resolveBody: () => Promise.resolve('__body__') }) + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url: '/nested/procedure', + headers: {}, + body: '__encoded__', + }) + transport.send.mockResolvedValueOnce({ + status: 500, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), + }) codec.decodeResponse.mockRejectedValueOnce(error) await expect(link.call(['nested', 'procedure'], {}, { context: {} })).rejects.toBe(error) @@ -161,7 +181,11 @@ describe('responseValidationLinkPlugin', () => { }) vi.mocked(reconcileORPCError).mockResolvedValueOnce(reconciled) - const output = await link.call(['stream'], {}, { context: {} }) as AsyncIteratorObject + const output = (await link.call( + ['stream'], + {}, + { context: {} }, + )) as AsyncIteratorObject expect(reconcileORPCError).not.toHaveBeenCalled() await expect(output.next()).resolves.toEqual({ done: false, value: 1 }) @@ -172,7 +196,10 @@ describe('responseValidationLinkPlugin', () => { }) it('preserves event meta of reconciled mid-stream errors', async () => { - const error = withEventMeta(new ORPCError('TEST', { data: { value: '123' } }), { id: 'id-1', retry: 1000 }) + const error = withEventMeta(new ORPCError('TEST', { data: { value: '123' } }), { + id: 'id-1', + retry: 1000, + }) const reconciled = new ORPCError('TEST', { data: { value: 123 } }) mockResponse('/stream', { @@ -183,9 +210,13 @@ describe('responseValidationLinkPlugin', () => { }) vi.mocked(reconcileORPCError).mockResolvedValueOnce(reconciled) - const output = await link.call(['stream'], {}, { context: {} }) as AsyncIteratorObject + const output = (await link.call( + ['stream'], + {}, + { context: {} }, + )) as AsyncIteratorObject - const thrown = await output.next().catch(e => e) + const thrown = await output.next().catch((e) => e) expect(thrown).toBeInstanceOf(ORPCError) expect(thrown.data).toEqual({ value: 123 }) @@ -202,14 +233,19 @@ describe('responseValidationLinkPlugin', () => { })(), }) - const output = await link.call(['stream'], {}, { context: {} }) as AsyncIteratorObject + const output = (await link.call( + ['stream'], + {}, + { context: {} }, + )) as AsyncIteratorObject await expect(output.next()).rejects.toBe(error) expect(reconcileORPCError).not.toHaveBeenCalled() }) it('marks mid-stream errors as defined and applies the error data schema', async () => { - const { reconcileORPCError: actualReconcileORPCError } = await vi.importActual('../error-utils') + const { reconcileORPCError: actualReconcileORPCError } = + await vi.importActual('../error-utils.ts') vi.mocked(reconcileORPCError).mockImplementation(actualReconcileORPCError) onTestFinished(() => { vi.mocked(reconcileORPCError).mockReset() @@ -223,11 +259,15 @@ describe('responseValidationLinkPlugin', () => { })(), }) - const output = await link.call(['stream'], {}, { context: {} }) as AsyncIteratorObject + const output = (await link.call( + ['stream'], + {}, + { context: {} }, + )) as AsyncIteratorObject await expect(output.next()).resolves.toEqual({ done: false, value: 1 }) - await expect(output.next()).rejects.toSatisfy((error: any) => - error instanceof ORPCError && error.defined === true && error.data.value === 123, + await expect(output.next()).rejects.toSatisfy( + (error: any) => error instanceof ORPCError && error.defined && error.data.value === 123, ) }) }) diff --git a/packages/contract/src/plugins/response-validation.ts b/packages/contract/src/plugins/response-validation.ts index a0f4e72770..756453800a 100644 --- a/packages/contract/src/plugins/response-validation.ts +++ b/packages/contract/src/plugins/response-validation.ts @@ -1,12 +1,13 @@ import type { ClientContext } from '@orpc/client' +import { ORPCError, wrapAsyncIteratorPreservingEventMeta } from '@orpc/client' import type { StandardLinkOptions, StandardLinkPlugin } from '@orpc/client/standard' import type { ThrowableError } from '@orpc/shared' -import type { RouterContract } from '../router' -import { ORPCError, wrapAsyncIteratorPreservingEventMeta } from '@orpc/client' import { isAsyncIteratorObject, override, toArray } from '@orpc/shared' -import { ValidationError } from '../error' -import { reconcileORPCError } from '../error-utils' -import { getProcedureContractOrThrow } from '../router-utils' + +import { reconcileORPCError } from '../error-utils.ts' +import { ValidationError } from '../error.ts' +import { getProcedureContractOrThrow } from '../router-utils.ts' +import type { RouterContract } from '../router.ts' /** * Validates server responses against contract schemas before your application uses them. @@ -14,13 +15,12 @@ import { getProcedureContractOrThrow } from '../router-utils' * * @see {@link https://orpc.dev/docs/plugins/response-validation | Response Validation Plugin} */ -export class ResponseValidationLinkPlugin implements StandardLinkPlugin { +export class ResponseValidationLinkPlugin< + T extends ClientContext, +> implements StandardLinkPlugin { name = '~response-validation' - constructor( - private readonly contract: RouterContract, - ) { - } + constructor(private readonly contract: RouterContract) {} init(options: StandardLinkOptions): StandardLinkOptions { return { @@ -47,15 +47,14 @@ export class ResponseValidationLinkPlugin implements St * @warning * Remember use `override` for AsyncIteratorObject to remain other special properties */ - return override(output, wrapAsyncIteratorPreservingEventMeta( + return override( output, - { mapError: reconcileError }, - )) as typeof output + wrapAsyncIteratorPreservingEventMeta(output, { mapError: reconcileError }), + ) as typeof output } return output - } - catch (error) { + } catch (error) { throw await reconcileError(error as ThrowableError) } }, diff --git a/packages/contract/src/procedure-client.test-d.ts b/packages/contract/src/procedure-client.test-d.ts index 0bdec157b0..fa20f93481 100644 --- a/packages/contract/src/procedure-client.test-d.ts +++ b/packages/contract/src/procedure-client.test-d.ts @@ -1,14 +1,15 @@ import type { Client, ORPCError, ThrowableError } from '@orpc/client' -import type { ProcedureContractClient } from './procedure-client' import { z } from 'zod' +import type { ProcedureContractClient } from './procedure-client.ts' + // 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 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)) }), + data: z.object({ id: z.string().transform((s) => Number(s)) }), message: 'base', }, } @@ -16,7 +17,12 @@ const errorMap = { describe('ProcedureContractClient', () => { it('is a client', () => { expectTypeOf< - ProcedureContractClient<{ cache?: boolean }, typeof inputSchema, typeof outputSchema, typeof errorMap> + ProcedureContractClient< + { cache?: boolean }, + typeof inputSchema, + typeof outputSchema, + typeof errorMap + > >().toEqualTypeOf< Client< { cache?: boolean }, diff --git a/packages/contract/src/procedure-client.ts b/packages/contract/src/procedure-client.ts index fb62402f58..22c1a856d7 100644 --- a/packages/contract/src/procedure-client.ts +++ b/packages/contract/src/procedure-client.ts @@ -1,7 +1,8 @@ import type { Client, ClientContext } from '@orpc/client' import type { ThrowableError } from '@orpc/shared' -import type { ErrorMap, ORPCErrorFromErrorMap } from './error' -import type { AnySchema, InferSchemaInput, InferSchemaOutput } from './schema' + +import type { ErrorMap, ORPCErrorFromErrorMap } from './error.ts' +import type { AnySchema, InferSchemaInput, InferSchemaOutput } from './schema.ts' export type ProcedureContractClient< TClientContext extends ClientContext, diff --git a/packages/contract/src/procedure.test.ts b/packages/contract/src/procedure.test.ts index 449b8305e6..77fc889b51 100644 --- a/packages/contract/src/procedure.test.ts +++ b/packages/contract/src/procedure.test.ts @@ -1,4 +1,4 @@ -import { ProcedureContract } from './procedure' +import { ProcedureContract } from './procedure.ts' describe('procedureContract', () => { const procedure = new ProcedureContract({ @@ -15,14 +15,18 @@ describe('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) + 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', () => { @@ -39,8 +43,12 @@ describe('procedureContract', () => { 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) + 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 8b446a409d..d2e231245f 100644 --- a/packages/contract/src/procedure.ts +++ b/packages/contract/src/procedure.ts @@ -1,8 +1,9 @@ -import type { ErrorMap } from './error' -import type { AnyMetaPlugin, Meta } from './meta' -import type { AnySchema } from './schema' import { getConstructor, isTypescriptObject } from '@orpc/shared' +import type { ErrorMap } from './error.ts' +import type { AnyMetaPlugin, Meta } from './meta.ts' +import type { AnySchema } from './schema.ts' + export interface ProcedureContractDefinition< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, @@ -28,7 +29,7 @@ export class ProcedureContract< > { '~orpc': ProcedureContractDefinition - constructor(def: ProcedureContractDefinition) { + 'constructor'(def: ProcedureContractDefinition) { this['~orpc'] = def } @@ -47,12 +48,14 @@ export class ProcedureContract< } 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)) + 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)) ) } } diff --git a/packages/contract/src/router-client.test-d.ts b/packages/contract/src/router-client.test-d.ts index 5ffbc347de..2035973086 100644 --- a/packages/contract/src/router-client.test-d.ts +++ b/packages/contract/src/router-client.test-d.ts @@ -1,13 +1,14 @@ import type { Client, ClientContext, NestedClient, ORPCError, ThrowableError } from '@orpc/client' -import type { RouterContractClient } from './router-client' import { z } from 'zod' -import { oc } from './builder' + +import { oc } from './builder.ts' +import type { RouterContractClient } from './router-client.ts' // 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({ +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)) }), + data: z.object({ id: z.string().transform((s) => Number(s)) }), }, }) @@ -22,7 +23,9 @@ const router = { describe('RouterContractClient', () => { it('is a NestedClient', () => { - expectTypeOf>().toExtend>() + expectTypeOf>().toExtend< + NestedClient + >() }) it('maps to ProcedureContractClient', () => { @@ -41,12 +44,8 @@ describe('RouterContractClient', () => { > >() - expectTypeOf().toEqualTypeOf< - ClientType['ping'] - >() + expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf< - ClientType['pong'] - >() + expectTypeOf().toEqualTypeOf() }) }) diff --git a/packages/contract/src/router-client.ts b/packages/contract/src/router-client.ts index 416ab6bd63..e548f170ae 100644 --- a/packages/contract/src/router-client.ts +++ b/packages/contract/src/router-client.ts @@ -1,7 +1,8 @@ import type { ClientContext } from '@orpc/client' -import type { ProcedureContract } from './procedure' -import type { ProcedureContractClient } from './procedure-client' -import type { RouterContract } from './router' + +import type { ProcedureContractClient } from './procedure-client.ts' +import type { ProcedureContract } from './procedure.ts' +import type { RouterContract } from './router.ts' /** * Client type inferred from a router contract, preserving its shape. @@ -9,9 +10,14 @@ import type { RouterContract } from './router' * * @see {@link https://orpc.dev/docs/client/client-side | Client-Side Clients} */ -export type RouterContractClient - = TRouter extends ProcedureContract +export type RouterContractClient< + TRouter extends RouterContract, + TClientContext extends ClientContext = object, +> = + TRouter extends ProcedureContract ? ProcedureContractClient : { - [K in keyof TRouter]: TRouter[K] extends RouterContract ? RouterContractClient : 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 99cbd7db5e..34eaaaebf4 100644 --- a/packages/contract/src/router-utils.test-d.ts +++ b/packages/contract/src/router-utils.test-d.ts @@ -1,14 +1,15 @@ -import type { MergedErrorMap } from './error-utils' -import type { ProcedureContract } from './procedure' -import type { AugmentedContractRouter } from './router-utils' -import type { Schema } from './schema' import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' -import { oc } from './builder' + +import { oc } from './builder.ts' +import type { MergedErrorMap } from './error-utils.ts' +import type { ProcedureContract } from './procedure.ts' +import type { AugmentedContractRouter } from './router-utils.ts' +import type { Schema } from './schema.ts' // 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 inputSchema = z.object({ input: z.number().transform((n) => `${n}`) }) +const outputSchema = z.object({ output: z.string().transform((s) => Number(s)) }) const ping = oc.input(inputSchema).output(outputSchema) const pong = oc.errors({ PONG: { message: 'pong' } }) diff --git a/packages/contract/src/router-utils.test.ts b/packages/contract/src/router-utils.test.ts index 53215bd790..60c277172d 100644 --- a/packages/contract/src/router-utils.test.ts +++ b/packages/contract/src/router-utils.test.ts @@ -1,10 +1,16 @@ -import type { AnyMetaPlugin } from './meta' import { getOwn } from '@orpc/shared' 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' + +import * as ErrorUtilsModule from './error-utils.ts' +import * as MetaUtilsModule from './meta-utils.ts' +import type { AnyMetaPlugin } from './meta.ts' +import { ProcedureContract } from './procedure.ts' +import { + augmentContractRouter, + getProcedureContractOrThrow, + getRouterContract, + minifyRouterContract, +} from './router-utils.ts' const mergeErrorMapSpy = vi.spyOn(ErrorUtilsModule, 'mergeErrorMap') const resolveMetaPluginsSpy = vi.spyOn(MetaUtilsModule, 'resolveMetaPlugins') @@ -66,16 +72,18 @@ const router = { }), invalid: 'invalid' as any, nested: callable({ - ping: callable(new ProcedureContract({ - errorMap: { - NESTED_PING_ERROR: { - data: schema2, + ping: callable( + new ProcedureContract({ + errorMap: { + NESTED_PING_ERROR: { + data: schema2, + }, }, - }, - meta: { nestedPing: true }, - inputSchemas: [], - outputSchemas: [schema2], - })), + meta: { nestedPing: true }, + inputSchemas: [], + outputSchemas: [schema2], + }), + ), invalid: 'invalid' as any, }), } @@ -103,7 +111,11 @@ describe('augmentContractRouter', () => { expect(actual).toBeInstanceOf(ProcedureContract) expect(actual).not.toBe(original) - expect(mergeErrorMapSpy).toHaveBeenNthCalledWith(callIndex, options.errorMap, original['~orpc'].errorMap) + expect(mergeErrorMapSpy).toHaveBeenNthCalledWith( + callIndex, + options.errorMap, + original['~orpc'].errorMap, + ) expect(resolveMetaPluginsSpy).toHaveBeenNthCalledWith( callIndex, options.meta, @@ -170,7 +182,10 @@ describe('augmentContractRouter', () => { it('keeps an own `__proto__` key as a route instead of re-parenting the result', () => { const options = createAugmentOptions() - const protoRouter = Object.fromEntries([['__proto__', router.ping], ['other', router.pong]]) + const protoRouter = Object.fromEntries([ + ['__proto__', router.ping], + ['other', router.pong], + ]) const augmented = augmentContractRouter(protoRouter, options) as any expect(Object.getPrototypeOf(augmented)).toBe(Object.prototype) @@ -223,13 +238,21 @@ describe('getProcedureContractOrThrow', () => { 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.`) + 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(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([])) }) }) @@ -282,7 +305,10 @@ describe('minifyRouterContract', () => { }) it('keeps an own `__proto__` key as a route instead of re-parenting the result', () => { - const protoRouter = Object.fromEntries([['__proto__', router.ping], ['other', router.pong]]) + const protoRouter = Object.fromEntries([ + ['__proto__', router.ping], + ['other', router.pong], + ]) const minified = minifyRouterContract(protoRouter) as any expect(Object.getPrototypeOf(minified)).toBe(Object.prototype) diff --git a/packages/contract/src/router-utils.ts b/packages/contract/src/router-utils.ts index c63a1b907d..d662a6dd10 100644 --- a/packages/contract/src/router-utils.ts +++ b/packages/contract/src/router-utils.ts @@ -1,18 +1,21 @@ -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 { getOwn, isTypescriptObject, setOwn } from '@orpc/shared' -import { mergeErrorMap } from './error-utils' -import { resolveMetaPlugins } from './meta-utils' -import { ProcedureContract } from './procedure' -export type AugmentedContractRouter - = T extends ProcedureContract +import type { MergedErrorMap } from './error-utils.ts' +import { mergeErrorMap } from './error-utils.ts' +import type { ErrorMap } from './error.ts' +import { resolveMetaPlugins } from './meta-utils.ts' +import type { AnyMetaPlugin, Meta } from './meta.ts' +import type { AnyProcedureContract } from './procedure.ts' +import { ProcedureContract } from './procedure.ts' +import type { RouterContract } from './router.ts' + +export type AugmentedContractRouter = + T extends ProcedureContract ? ProcedureContract<$InputSchema, $OutputSchema, MergedErrorMap> : { - [K in keyof T]: T[K] extends RouterContract ? AugmentedContractRouter : never + [K in keyof T]: T[K] extends RouterContract + ? AugmentedContractRouter + : never } export interface AugmentContractRouterOptions { @@ -58,7 +61,10 @@ export function augmentContractRouter Number(n)) }) -const schema2 = z.object({ schema2: z.string().transform(n => Number(n)) }) - -const ping = oc - .input(schema1) - .output(schema2) - -const pong = oc - .errors({ - BAD_GATEWAY: { - data: schema1, - }, - }) - -const notFound = oc - .errors({ - NOT_FOUND: { - data: schema2, - }, - }) +const schema1 = z.object({ schema1: z.string().transform((n) => Number(n)) }) +const schema2 = z.object({ schema2: z.string().transform((n) => Number(n)) }) + +const ping = oc.input(schema1).output(schema2) + +const pong = oc.errors({ + BAD_GATEWAY: { + data: schema1, + }, +}) + +const notFound = oc.errors({ + NOT_FOUND: { + data: schema2, + }, +}) const router = { ping, @@ -76,8 +79,12 @@ it('InferRouterContractErrorMap', () => { it('InferRouterContractErrors', () => { type Errors = InferRouterContractErrors - expectTypeOf().toEqualTypeOf | ThrowableError>() - expectTypeOf().toEqualTypeOf | ThrowableError>() + expectTypeOf().toEqualTypeOf< + ORPCError<'BAD_GATEWAY', { schema1: number }> | ThrowableError + >() + expectTypeOf().toEqualTypeOf< + ORPCError<'NOT_FOUND', { schema2: number }> | ThrowableError + >() }) it('InferRouterContractError', () => { diff --git a/packages/contract/src/router.ts b/packages/contract/src/router.ts index a4ea9e47d5..e5a506e176 100644 --- a/packages/contract/src/router.ts +++ b/packages/contract/src/router.ts @@ -1,16 +1,17 @@ import type { ThrowableError } from '@orpc/shared' -import type { ORPCErrorFromErrorMap } from './error' -import type { AnyProcedureContract, ProcedureContract } from './procedure' -import type { InferSchemaInput, InferSchemaOutput } from './schema' + +import type { ORPCErrorFromErrorMap } from './error.ts' +import type { AnyProcedureContract, ProcedureContract } from './procedure.ts' +import type { InferSchemaInput, InferSchemaOutput } from './schema.ts' /** * A router contract: a single procedure contract or a nested record of them. * * @see {@link https://orpc.dev/docs/contract/client-factory | Contract Client Factory} */ -export type RouterContract - = | AnyProcedureContract - | { +export type RouterContract = + | AnyProcedureContract + | { [k: string]: RouterContract } @@ -19,8 +20,8 @@ export type RouterContract * * @see {@link https://orpc.dev/docs/contract/router#infer-router-contract-inputs | Router Contract - Infer Router Contract Inputs} */ -export type InferRouterContractInputs - = T extends ProcedureContract +export type InferRouterContractInputs = + T extends ProcedureContract ? InferSchemaInput : { [K in keyof T]: T[K] extends RouterContract ? InferRouterContractInputs : never @@ -31,8 +32,8 @@ export type InferRouterContractInputs * * @see {@link https://orpc.dev/docs/contract/router#infer-router-contract-outputs | Router Contract - Infer Router Contract Outputs} */ -export type InferRouterContractOutputs - = T extends ProcedureContract +export type InferRouterContractOutputs = + T extends ProcedureContract ? InferSchemaOutput : { [K in keyof T]: T[K] extends RouterContract ? InferRouterContractOutputs : never @@ -43,8 +44,8 @@ export type InferRouterContractOutputs * * @see {@link https://orpc.dev/docs/contract/router#infer-router-contract-error-map | Router Contract - Infer Router Contract Error Map} */ -export type InferRouterContractErrorMap - = T extends ProcedureContract +export type InferRouterContractErrorMap = + T extends ProcedureContract ? UErrorMap : { [K in keyof T]: T[K] extends RouterContract ? InferRouterContractErrorMap : never @@ -55,8 +56,8 @@ export type InferRouterContractErrorMap * * @see {@link https://orpc.dev/docs/contract/router#infer-router-contract-error | Router Contract - Infer Router Contract Error} */ -export type InferRouterContractError - = T extends ProcedureContract +export type InferRouterContractError = + T extends ProcedureContract ? ORPCErrorFromErrorMap | ThrowableError : { [K in keyof T]: T[K] extends RouterContract ? InferRouterContractError : never @@ -67,8 +68,8 @@ export type InferRouterContractError * * @see {@link https://orpc.dev/docs/contract/router#infer-router-contract-errors | Router Contract - Infer Router Contract Errors} */ -export type InferRouterContractErrors - = T extends ProcedureContract +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-built-in.test.ts b/packages/contract/src/schema-built-in.test.ts index 43f6ef1a5f..ac37ba6d31 100644 --- a/packages/contract/src/schema-built-in.test.ts +++ b/packages/contract/src/schema-built-in.test.ts @@ -2,12 +2,19 @@ import type { AnyORPCError } from '@orpc/client' import { ORPCError } from '@orpc/client' import { getEventMeta, withEventMeta } from '@standard-server/core' import * as z from 'zod' -import { ValidationError } from './error' -import { asyncIteratorObject, getAsyncIteratorObjectSchemaDetails, getAsyncIteratorObjectSchemasDetails } from './schema-built-in' + +import { ValidationError } from './error.ts' +import { + asyncIteratorObject, + getAsyncIteratorObjectSchemaDetails, + getAsyncIteratorObjectSchemasDetails, +} from './schema-built-in.ts' const ORDER_SCHEMA = z.object({ order: z.number() }) -function assertValidationSuccess(result: T): asserts result is T & { issues: undefined } { +function assertValidationSuccess( + result: T, +): asserts result is T & { issues: undefined } { if (result.issues) { throw new Error('Validation failed') } @@ -23,11 +30,13 @@ describe('asyncIteratorObject', () => { it('can validate yields and preserve meta', async () => { const schema = asyncIteratorObject(ORDER_SCHEMA) - const result = await schema['~standard'].validate((async function* () { - yield { order: 1 } - yield withEventMeta({ order: 2 }, { id: 'id-2' }) - yield { order: '3' } - })()) + const result = await schema['~standard'].validate( + (async function* () { + yield { order: 1 } + yield withEventMeta({ order: 2 }, { id: 'id-2' }) + yield { order: '3' } + })(), + ) assertValidationSuccess(result) @@ -44,8 +53,7 @@ describe('asyncIteratorObject', () => { try { await result.value.next() throw new Error('Expected AsyncIteratorObject validation to fail') - } - catch (error) { + } catch (error) { expect(error).toBeInstanceOf(ORPCError) expect((error as AnyORPCError).code).toEqual('ASYNC_ITERATOR_OBJECT_VALIDATION_FAILED') expect((error as AnyORPCError).cause).toBeInstanceOf(ValidationError) @@ -57,9 +65,11 @@ describe('asyncIteratorObject', () => { it('can validate returns and preserve meta', async () => { const schema = asyncIteratorObject(ORDER_SCHEMA, ORDER_SCHEMA) - const result = await schema['~standard'].validate((async function* () { - return { order: 1 } - })()) + const result = await schema['~standard'].validate( + (async function* () { + return { order: 1 } + })(), + ) assertValidationSuccess(result) @@ -72,9 +82,11 @@ describe('asyncIteratorObject', () => { it('not required returns schema', async () => { const schema = asyncIteratorObject(ORDER_SCHEMA) - const result = await schema['~standard'].validate((async function* () { - return 'anything' - })()) + const result = await schema['~standard'].validate( + (async function* () { + return 'anything' + })(), + ) assertValidationSuccess(result) @@ -107,16 +119,17 @@ describe('asyncIteratorObject', () => { let cleanupCalled = false const schema = asyncIteratorObject(ORDER_SCHEMA) - const result = await schema['~standard'].validate((async function* () { - try { - yield { order: 1 } - yield { order: '2' } - yield { order: 3 } - } - finally { - cleanupCalled = true - } - })()) + const result = await schema['~standard'].validate( + (async function* () { + try { + yield { order: 1 } + yield { order: '2' } + yield { order: 3 } + } finally { + cleanupCalled = true + } + })(), + ) assertValidationSuccess(result) @@ -140,10 +153,16 @@ it('getAsyncIteratorObjectSchemasDetails', async () => { const yieldSchema = ORDER_SCHEMA const returnSchema = z.object({ done: z.boolean() }) - expect(getAsyncIteratorObjectSchemasDetails([asyncIteratorObject(yieldSchema, returnSchema), asyncIteratorObject(yieldSchema)])) - .toEqual({ yieldSchemas: [yieldSchema, yieldSchema], returnSchemas: [returnSchema] }) + expect( + getAsyncIteratorObjectSchemasDetails([ + asyncIteratorObject(yieldSchema, returnSchema), + asyncIteratorObject(yieldSchema), + ]), + ).toEqual({ yieldSchemas: [yieldSchema, yieldSchema], returnSchemas: [returnSchema] }) - expect(getAsyncIteratorObjectSchemasDetails([asyncIteratorObject(yieldSchema), z.object({})])).toBeUndefined() + expect( + getAsyncIteratorObjectSchemasDetails([asyncIteratorObject(yieldSchema), z.object({})]), + ).toBeUndefined() expect(getAsyncIteratorObjectSchemasDetails([])).toBeUndefined() expect(getAsyncIteratorObjectSchemasDetails(undefined)).toBeUndefined() }) diff --git a/packages/contract/src/schema-built-in.ts b/packages/contract/src/schema-built-in.ts index 6539898d9d..c1466274f4 100644 --- a/packages/contract/src/schema-built-in.ts +++ b/packages/contract/src/schema-built-in.ts @@ -1,10 +1,13 @@ -import type { AsyncIteratorClass } from '@orpc/shared' -import type { AnySchema, Schema } from './schema' import { ORPCError, wrapAsyncIteratorPreservingEventMeta } from '@orpc/client' +import type { AsyncIteratorClass } from '@orpc/shared' import { isAsyncIteratorObject, ORPC_NAME, override } from '@orpc/shared' -import { ValidationError } from './error' -const ASYNC_ITERATOR_OBJECT_SCHEMA_DETAILS_SYMBOL = /* @__PURE__ */ Symbol.for('ORPC_ASYNC_ITERATOR_OBJECT_SCHEMA_DETAILS') +import { ValidationError } from './error.ts' +import type { AnySchema, Schema } from './schema.ts' + +const ASYNC_ITERATOR_OBJECT_SCHEMA_DETAILS_SYMBOL = /* @__PURE__ */ Symbol.for( + 'ORPC_ASYNC_ITERATOR_OBJECT_SCHEMA_DETAILS', +) export interface AsyncIteratorObjectSchemaDetails { yieldSchema: AnySchema @@ -20,10 +23,16 @@ export interface AsyncIteratorObjectSchemaDetails { export function asyncIteratorObject( yieldSchema: Schema, returnSchema?: Schema, -): Schema, AsyncIteratorClass> { +): Schema< + AsyncIteratorObject, + AsyncIteratorClass +> { return { '~standard': { - [ASYNC_ITERATOR_OBJECT_SCHEMA_DETAILS_SYMBOL as any]: { yieldSchema, returnSchema } satisfies AsyncIteratorObjectSchemaDetails, + [ASYNC_ITERATOR_OBJECT_SCHEMA_DETAILS_SYMBOL as any]: { + yieldSchema, + returnSchema, + } satisfies AsyncIteratorObjectSchemaDetails, vendor: ORPC_NAME, version: 1, validate(iterator) { @@ -62,7 +71,9 @@ export function asyncIteratorObject { it('without map', async () => { const schema = type() const val = {} - expect((await schema['~standard'].validate(val) as any).value).toBe(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(((await schema['~standard'].validate(val)) as any).value).toBe('__mapped__') expect(check).toHaveBeenCalledWith(val) }) }) diff --git a/packages/contract/src/schema-utils.ts b/packages/contract/src/schema-utils.ts index 2c7b6d7be8..2e65980bb9 100644 --- a/packages/contract/src/schema-utils.ts +++ b/packages/contract/src/schema-utils.ts @@ -1,10 +1,11 @@ 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) +import type { Schema, SchemaIssue } from './schema.ts' + +export type TypeRest = + | [map: (input: TInput) => Promisable] + | (IsEqual extends true ? [] : never) /** * Create a schema for things can be trust without validation. @@ -27,7 +28,7 @@ export function type( version: 1, async validate(value) { if (map) { - return { value: await map(value as TInput) as TOutput } + return { value: (await map(value as TInput)) as TOutput } } return { value: value as TOutput } @@ -50,7 +51,10 @@ export function isSchemaIssue(issue: unknown): issue is SchemaIssue { } if ( - !issue.path.every(segment => isPropertyKey(segment) || (isTypescriptObject(segment) && isPropertyKey(segment.key))) + !issue.path.every( + (segment) => + isPropertyKey(segment) || (isTypescriptObject(segment) && isPropertyKey(segment.key)), + ) ) { return false } diff --git a/packages/contract/src/schema.test-d.ts b/packages/contract/src/schema.test-d.ts index 60b33c21bf..9d8f10c03a 100644 --- a/packages/contract/src/schema.test-d.ts +++ b/packages/contract/src/schema.test-d.ts @@ -1,11 +1,18 @@ -import type { AnySchema, InferSchemaInput, InferSchemaOutput, MergedSchema, Schema } from './schema' import { type as arktypeType } from 'arktype' import * as v from 'valibot' -import z from 'zod' +import * as z from 'zod' + +import type { + AnySchema, + InferSchemaInput, + InferSchemaOutput, + MergedSchema, + Schema, +} from './schema.ts' // 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 inputSchema = z.object({ input: z.number().transform((n) => `${n}`) }) +const outputSchema = z.object({ output: z.string().transform((s) => Number(s)) }) describe('Schema', () => { it('supports any standard schema', () => { @@ -13,7 +20,10 @@ describe('Schema', () => { value: z.string().transform(() => 123), }) const _valibot: AnySchema = v.object({ - value: v.pipe(v.string(), v.transform(() => 123)), + value: v.pipe( + v.string(), + v.transform(() => 123), + ), }) const _arktype: AnySchema = arktypeType({ value: 'string', @@ -49,16 +59,22 @@ describe('MergedSchema', () => { type TMerged = MergedSchema> expectTypeOf().toEqualTypeOf< - Schema<{ schema1: number } & { schema2: string } & { schema3: string }, { schema1: string } & { schema2: number } & { schema3: boolean }> + Schema< + { schema1: number } & { schema2: string } & { schema3: string }, + { schema1: string } & { schema2: number } & { schema3: boolean } + > >() }) it('works with zod, valibot, arktype', () => { const schema1 = z.object({ - schema1: z.number().transform(n => `${n}`), + schema1: z.number().transform((n) => `${n}`), }) const schema2 = v.object({ - schema2: v.pipe(v.string(), v.transform(s => Number(s))), + schema2: v.pipe( + v.string(), + v.transform((s) => Number(s)), + ), }) const schema3 = arktypeType({ schema3: 'string', @@ -66,7 +82,10 @@ describe('MergedSchema', () => { type TMerged = MergedSchema> expectTypeOf().toEqualTypeOf< - Schema<{ schema1: number } & { schema2: string } & { schema3: string }, { schema1: string } & { schema2: number } & { schema3: string }> + Schema< + { schema1: number } & { schema2: string } & { schema3: string }, + { schema1: string } & { schema2: number } & { schema3: string } + > >() }) @@ -76,7 +95,11 @@ describe('MergedSchema', () => { type Schema3 = Schema<{ schema3: string }, { schema3: boolean }> type TMerged = MergedSchema> - expectTypeOf>().toEqualTypeOf<{ schema1: number } & { schema2: string } & { schema3: string }>() - expectTypeOf>().toEqualTypeOf<{ schema1: string } & { schema2: number } & { schema3: boolean }>() + expectTypeOf>().toEqualTypeOf< + { schema1: number } & { schema2: string } & { schema3: string } + >() + expectTypeOf>().toEqualTypeOf< + { schema1: string } & { schema2: number } & { schema3: boolean } + >() }) }) diff --git a/packages/contract/src/schema.ts b/packages/contract/src/schema.ts index 0e4684519a..846ff8ed42 100644 --- a/packages/contract/src/schema.ts +++ b/packages/contract/src/schema.ts @@ -1,4 +1,4 @@ -// eslint-disable-next-line no-restricted-imports +// oxlint-disable-next-line no-restricted-imports import type { StandardSchemaV1 } from '@standard-schema/spec' /** @@ -20,17 +20,19 @@ export type SchemaIssue = StandardSchemaV1.Issue * * @see {@link https://orpc.dev/docs/metadata | Metadata} */ -export type InferSchemaInput = T extends StandardSchemaV1 ? UInput : never +export type InferSchemaInput = + T extends StandardSchemaV1 ? UInput : never /** * Infers the output type of a schema. * * @see {@link https://orpc.dev/docs/metadata | Metadata} */ -export type InferSchemaOutput = T extends StandardSchemaV1 ? UOutput : never +export type InferSchemaOutput = + T extends StandardSchemaV1 ? UOutput : never -export type MergedSchema - = T extends Schema +export type MergedSchema = + T extends Schema ? U extends Schema ? Schema : never diff --git a/packages/contract/tsconfig.json b/packages/contract/tsconfig.json index db6a408836..9979a0715c 100644 --- a/packages/contract/tsconfig.json +++ b/packages/contract/tsconfig.json @@ -1,15 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/effect/README.md b/packages/effect/README.md index 0b2817a29b..800b60063a 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/effect/build.config.ts b/packages/effect/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/effect/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/effect/package.json b/packages/effect/package.json index 04badb7406..0dabd9a810 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -1,66 +1,62 @@ { "name": "@orpc/effect", - "type": "module", "version": "2.0.0-beta.48", "description": "Effect integration for oRPC: build typesafe procedures with Effect", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "effect", + "effect-ts", + "orpc", + "rpc", + "typesafe", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/effect" }, - "keywords": [ - "orpc", - "effect", - "effect-ts", - "rpc", - "typescript", - "typesafe" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": [ - "./dist/extensions/effect.mjs", - "./dist/extensions/schema.mjs" + "./dist/extensions/effect.js", + "./dist/extensions/schema.js" ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./extensions/effect": "./src/extensions/effect.ts", + "./extensions/schema": "./src/extensions/schema.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./extensions/effect": { - "types": "./dist/extensions/effect.d.mts", - "import": "./dist/extensions/effect.mjs", - "default": "./dist/extensions/effect.mjs" + "types": "./dist/extensions/effect.d.ts", + "import": "./dist/extensions/effect.js", + "default": "./dist/extensions/effect.js" }, "./extensions/schema": { - "types": "./dist/extensions/schema.d.mts", - "import": "./dist/extensions/schema.mjs", - "default": "./dist/extensions/schema.mjs" + "types": "./dist/extensions/schema.d.ts", + "import": "./dist/extensions/schema.js", + "default": "./dist/extensions/schema.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts", - "./extensions/effect": "./src/extensions/effect.ts", - "./extensions/schema": "./src/extensions/schema.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "effect": ">=4.0.0" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", @@ -71,5 +67,8 @@ }, "devDependencies": { "effect": "^4.1.2" + }, + "peerDependencies": { + "effect": ">=4.0.0" } } diff --git a/packages/effect/src/client.test-d.ts b/packages/effect/src/client.test-d.ts index 37f36ab7ad..4f69d33039 100644 --- a/packages/effect/src/client.test-d.ts +++ b/packages/effect/src/client.test-d.ts @@ -1,13 +1,19 @@ import type { Client, ClientContext, ORPCError } from '@orpc/client' -import type { EffectClient } from './client' import { isDefinedError } from '@orpc/client' import { Effect } from 'effect' -import { createEffectClient } from './client' -import { catchORPCErrorCodes } from './error' + +import type { EffectClient } from './client.ts' +import { createEffectClient } from './client.ts' +import { catchORPCErrorCodes } from './error.ts' describe('createEffectClient', () => { const client = {} as { - ping: Client | ORPCError<'NOT_FOUND', { id: number }>> + ping: Client< + ClientContext, + string, + number, + Error | ORPCError<'BAD_GATEWAY', { val: string }> | ORPCError<'NOT_FOUND', { id: number }> + > optional: Client nested: { pong: Client<{ cache: boolean }, { id: number }, { result: string }, Error> @@ -18,7 +24,10 @@ describe('createEffectClient', () => { it('procedures return effects with output and error types preserved', () => { expectTypeOf(effectClient.ping('test')).toEqualTypeOf< - Effect.Effect | ORPCError<'NOT_FOUND', { id: number }>> + Effect.Effect< + number, + Error | ORPCError<'BAD_GATEWAY', { val: string }> | ORPCError<'NOT_FOUND', { id: number }> + > >() expectTypeOf(effectClient.nested.pong({ id: 123 }, { context: { cache: true } })).toEqualTypeOf< diff --git a/packages/effect/src/client.test.ts b/packages/effect/src/client.test.ts index 655f9fe616..322e392a1d 100644 --- a/packages/effect/src/client.test.ts +++ b/packages/effect/src/client.test.ts @@ -1,7 +1,8 @@ import type { Client, ClientContext, ORPCError } from '@orpc/client' import { sleep } from '@orpc/shared' import { Effect, Exit, Schedule } from 'effect' -import { createEffectClient } from './client' + +import { createEffectClient } from './client.ts' beforeEach(() => { vi.clearAllMocks() @@ -42,21 +43,26 @@ describe('createEffectClient', () => { context: {}, signal: expect.any(AbortSignal), }) - expect(pongFn).toHaveBeenCalledWith({ id: 123 }, { - context: {}, - signal: expect.any(AbortSignal), - }) + expect(pongFn).toHaveBeenCalledWith( + { id: 123 }, + { + context: {}, + signal: expect.any(AbortSignal), + }, + ) }) it('forwards context, lastEventId, and merges the signal', async () => { pingFn.mockResolvedValue(42) const controller = new AbortController() - await Effect.runPromise(effectClient.ping('input', { - context: { cache: true }, - lastEventId: 'id-1', - signal: controller.signal, - })) + await Effect.runPromise( + effectClient.ping('input', { + context: { cache: true }, + lastEventId: 'id-1', + signal: controller.signal, + }), + ) const options = pingFn.mock.calls[0]![1] expect(options.context).toEqual({ cache: true }) @@ -87,9 +93,7 @@ describe('createEffectClient', () => { await sleep(0) expect(pingFn).not.toHaveBeenCalled() - await expect( - Effect.runPromise(effect.pipe(Effect.retry(Schedule.recurs(2)))), - ).resolves.toBe(42) + await expect(Effect.runPromise(effect.pipe(Effect.retry(Schedule.recurs(2))))).resolves.toBe(42) expect(pingFn).toHaveBeenCalledTimes(3) }) diff --git a/packages/effect/src/client.ts b/packages/effect/src/client.ts index 278ad233d3..8864099c2c 100644 --- a/packages/effect/src/client.ts +++ b/packages/effect/src/client.ts @@ -10,16 +10,17 @@ function callAsEffect client(input, { - ...options, - signal: anyAbortSignal([options.signal, signal]), - }), - catch: error => error as TError, + try: (signal) => + client(input, { + ...options, + signal: anyAbortSignal([options.signal, signal]), + }), + catch: (error) => error as TError, }) } -export type EffectClient - = T extends Client +export type EffectClient = + T extends Client ? (...rest: ClientRest) => Effect.Effect : { [K in keyof T]: T[K] extends AnyNestedClient ? EffectClient : never @@ -36,28 +37,32 @@ export type EffectClient export function createEffectClient(client: T): EffectClient { const cache = new Map>() - const proxy = new Proxy((...rest: any[]) => callAsEffect(client as Client, ...rest), { - get(target, prop) { - if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) { - return Reflect.get(target, prop) - } + const proxy = new Proxy( + (...rest: any[]) => + callAsEffect(client as Client, ...rest), + { + get(target, prop) { + if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) { + return Reflect.get(target, prop) + } - let effectClient = cache.get(prop) + let effectClient = cache.get(prop) - if (effectClient === undefined) { - const value = (client as Record)[prop] + if (effectClient === undefined) { + const value = (client as Record)[prop] - if (!isTypescriptObject(value)) { - return value - } + if (!isTypescriptObject(value)) { + return value + } - effectClient = createEffectClient(value as AnyNestedClient) - cache.set(prop, effectClient) - } + effectClient = createEffectClient(value as AnyNestedClient) + cache.set(prop, effectClient) + } - return effectClient + return effectClient + }, }, - }) + ) return proxy as EffectClient } diff --git a/packages/effect/src/context.test-d.ts b/packages/effect/src/context.test-d.ts index 71c9c3a6e0..5348e5d481 100644 --- a/packages/effect/src/context.test-d.ts +++ b/packages/effect/src/context.test-d.ts @@ -1,8 +1,15 @@ import type { AnyProcedure, Context as ORPCContext, ORPCErrorConstructorMap } from '@orpc/server' -import type { EffectWrapHandlerOptions, EffectWrapMiddlewareOptions, EffectWrapOptions, InferEffectServices, WithEffectContext } from './context' import { ORPCError } from '@orpc/server' import { Cause, Context, Effect } from 'effect' +import type { + EffectWrapHandlerOptions, + EffectWrapMiddlewareOptions, + EffectWrapOptions, + InferEffectServices, + WithEffectContext, +} from './context.ts' + class Service1 extends Context.Service< Service1, { @@ -23,45 +30,49 @@ type Wrap = NonNullable['effect/wrap']> describe('WithEffectContext', () => { it('effect/context', () => { - expectTypeOf['effect/context']>().toEqualTypeOf>() + expectTypeOf['effect/context']>().toEqualTypeOf< + Context.Context + >() }) describe('effect/wrap', () => { it('keeps the success type and allows changing the error type', () => { - const mapError: Wrap = effect => effect.pipe( - Effect.mapError(error => new ORPCError('BAD_REQUEST', { cause: error })), - ) + const mapError: Wrap = (effect) => + effect.pipe(Effect.mapError((error) => new ORPCError('BAD_REQUEST', { cause: error }))) - const mapSome: Wrap = effect => effect.pipe( - Effect.catchCause((cause) => { - const error = Cause.squash(cause) + const mapSome: Wrap = (effect) => + effect.pipe( + Effect.catchCause((cause) => { + const error = Cause.squash(cause) - if (error instanceof DomainError) { - return Effect.fail(new ORPCError('BAD_REQUEST', { cause: error })) - } + if (error instanceof DomainError) { + return Effect.fail(new ORPCError('BAD_REQUEST', { cause: error })) + } - return Effect.failCause(cause) - }), - ) + return Effect.failCause(cause) + }), + ) void mapError void mapSome // @ts-expect-error - must keep the success type - const invalidSuccess: Wrap = effect => effect.pipe(Effect.as('invalid')) + const invalidSuccess: Wrap = (effect) => effect.pipe(Effect.as('invalid')) void invalidSuccess }) it('does not allow requiring services', () => { // services from effect/context are available at runtime, but must be read without requirements - const serviceOption: Wrap = effect => Effect.serviceOption(Service1).pipe(Effect.andThen(effect)) + const serviceOption: Wrap = (effect) => + Effect.serviceOption(Service1).pipe(Effect.andThen(effect)) void serviceOption - // @ts-expect-error - wrap cannot require services - const requireService: Wrap = effect => Effect.gen(function* () { - yield* Service2 - return yield* effect - }) + const requireService: Wrap = (effect) => + // @ts-expect-error - wrap cannot require services + Effect.gen(function* () { + yield* Service2 + return yield* effect + }) void requireService }) @@ -80,8 +91,7 @@ describe('WithEffectContext', () => { if (opts.kind === 'middleware') { expectTypeOf(opts).toEqualTypeOf() expectTypeOf(opts).not.toHaveProperty('next') - } - else { + } else { expectTypeOf(opts).toEqualTypeOf() } @@ -95,19 +105,29 @@ describe('WithEffectContext', () => { describe('InferEffectServices', () => { it('infers services from effect/context', () => { - expectTypeOf>>().toEqualTypeOf() + expectTypeOf>>().toEqualTypeOf< + Service1 | Service2 + >() expectTypeOf>>().toEqualTypeOf() expectTypeOf>().toEqualTypeOf() }) it('does not distribute over union contexts', () => { - expectTypeOf | WithEffectContext>>().toEqualTypeOf() - expectTypeOf | { auth: boolean }>>().toEqualTypeOf() - expectTypeOf | WithEffectContext>>().toEqualTypeOf() + expectTypeOf< + InferEffectServices | WithEffectContext> + >().toEqualTypeOf() + expectTypeOf< + InferEffectServices | { auth: boolean }> + >().toEqualTypeOf() + expectTypeOf< + InferEffectServices | WithEffectContext> + >().toEqualTypeOf() }) it('infers services from intersection and any contexts', () => { - expectTypeOf & { auth: boolean }>>().toEqualTypeOf() + expectTypeOf< + InferEffectServices & { auth: boolean }> + >().toEqualTypeOf() expectTypeOf>().toEqualTypeOf() }) diff --git a/packages/effect/src/context.ts b/packages/effect/src/context.ts index 13074f3522..38bcf35f90 100644 --- a/packages/effect/src/context.ts +++ b/packages/effect/src/context.ts @@ -1,4 +1,9 @@ -import type { Context, MiddlewareOptions, ORPCErrorConstructorMap, ProcedureHandlerOptions } from '@orpc/server' +import type { + Context, + MiddlewareOptions, + ORPCErrorConstructorMap, + ProcedureHandlerOptions, +} from '@orpc/server' import type { Effect, Context as EffectContext } from 'effect' /** @@ -6,7 +11,10 @@ import type { Effect, Context as EffectContext } from 'effect' * * @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling} */ -export interface EffectWrapMiddlewareOptions extends Omit>, 'next'> { +export interface EffectWrapMiddlewareOptions extends Omit< + MiddlewareOptions>, + 'next' +> { kind: 'middleware' /** * The middleware's input, validated by the schemas defined before it. @@ -19,7 +27,11 @@ export interface EffectWrapMiddlewareOptions extends Omit> { +export interface EffectWrapHandlerOptions extends ProcedureHandlerOptions< + Context, + unknown, + ORPCErrorConstructorMap +> { kind: 'handler' } @@ -93,10 +105,9 @@ export interface WithEffectContext { * } * ``` */ - ['effect/wrap']?: (( - effect: Effect.Effect, - opts: EffectWrapOptions, - ) => Effect.Effect) | undefined + ['effect/wrap']?: + | ((effect: Effect.Effect, opts: EffectWrapOptions) => Effect.Effect) + | undefined } /** @@ -105,4 +116,8 @@ export interface WithEffectContext { * * @see {@link https://orpc.dev/docs/integrations/effect#effect-services | Effect Integration - Effect Services} */ -export type InferEffectServices = [T] extends [{ 'effect/context': EffectContext.Context }] ? Services : never +export type InferEffectServices = [T] extends [ + { 'effect/context': EffectContext.Context }, +] + ? Services + : never diff --git a/packages/effect/src/converter.test.ts b/packages/effect/src/converter.test.ts index a7b620714d..ba7e5ada46 100644 --- a/packages/effect/src/converter.test.ts +++ b/packages/effect/src/converter.test.ts @@ -1,7 +1,8 @@ import { Effect, Schema } from 'effect' import { z } from 'zod' -import { EffectSchemaToJsonSchemaConverter } from './converter' -import { toStandardSchema } from './schema' + +import { EffectSchemaToJsonSchemaConverter } from './converter.ts' +import { toStandardSchema } from './schema.ts' describe('effectSchemaToJsonSchemaConverter', () => { const converter = new EffectSchemaToJsonSchemaConverter() @@ -11,7 +12,11 @@ describe('effectSchemaToJsonSchemaConverter', () => { ['effect schema', toStandardSchema(Schema.String), true], ['non-effect schema', z.string(), false], ['undefined schema', undefined, false], - ['effect vendor without an effect schema', { '~standard': { version: 1, vendor: 'effect', validate: () => ({ value: 1 }) } }, false], + [ + 'effect vendor without an effect schema', + { '~standard': { version: 1, vendor: 'effect', validate: () => ({ value: 1 }) } }, + false, + ], ] as const)('matches %s', (_, schema, expected) => { expect(converter.condition(schema, 'input')).toBe(expected) }) @@ -19,9 +24,15 @@ describe('effectSchemaToJsonSchemaConverter', () => { it.each([ ['input', { type: 'string' }], - ['output', { anyOf: [{ type: 'number' }, { type: 'string', enum: ['Infinity', '-Infinity', 'NaN'] }] }], + [ + 'output', + { anyOf: [{ type: 'number' }, { type: 'string', enum: ['Infinity', '-Infinity', 'NaN'] }] }, + ], ] as const)('uses the requested %s direction', (direction, jsonSchema) => { - expect(converter.convert(toStandardSchema(Schema.NumberFromString), direction)).toEqual([jsonSchema, false]) + expect(converter.convert(toStandardSchema(Schema.NumberFromString), direction)).toEqual([ + jsonSchema, + false, + ]) }) it('does not mutate the schema', () => { @@ -31,31 +42,57 @@ describe('effectSchemaToJsonSchemaConverter', () => { }) it.each([ - ['onExcessProperty', { onExcessProperty: 'error' }, Schema.Struct({ a: Schema.String }), { additionalProperties: false }], - ['includeAnnotationKey', { includeAnnotationKey: (key: string) => key === 'x-custom' }, Schema.String.annotate({ 'x-custom': 'value' }), { 'x-custom': 'value' }], - ['referencePolicy', { referencePolicy: () => undefined }, Schema.Struct({ a: Schema.String.annotate({ identifier: 'A' }) }), { properties: { a: { type: 'string' } } }], - ['generateDescriptions', { generateDescriptions: true }, Schema.String.check(Schema.isMinLength(2)), { description: 'a value with a length of at least 2' }], + [ + 'onExcessProperty', + { onExcessProperty: 'error' }, + Schema.Struct({ a: Schema.String }), + { additionalProperties: false }, + ], + [ + 'includeAnnotationKey', + { includeAnnotationKey: (key: string) => key === 'x-custom' }, + Schema.String.annotate({ 'x-custom': 'value' }), + { 'x-custom': 'value' }, + ], + [ + 'referencePolicy', + { referencePolicy: () => undefined }, + Schema.Struct({ a: Schema.String.annotate({ identifier: 'A' }) }), + { properties: { a: { type: 'string' } } }, + ], + [ + 'generateDescriptions', + { generateDescriptions: true }, + Schema.String.check(Schema.isMinLength(2)), + { description: 'a value with a length of at least 2' }, + ], ] as const)('forwards the %s option to Effect', (_, options, schema, expected) => { const converter = new EffectSchemaToJsonSchemaConverter({ cache: true, ...options }) expect(converter.convert(toStandardSchema(schema), 'input')[0]).toMatchObject(expected) }) describe('definitions', () => { - it.each(['input', 'output'] as const)('keeps class definitions in the %s direction', (direction) => { - class Planet extends Schema.Class('Planet')({ name: Schema.String }) {} - - expect(converter.convert(toStandardSchema(Planet), direction)).toEqual([{ - $ref: '#/$defs/PlanetEncoded', - $defs: { - PlanetEncoded: { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - additionalProperties: true, + it.each(['input', 'output'] as const)( + 'keeps class definitions in the %s direction', + (direction) => { + class Planet extends Schema.Class('Planet')({ name: Schema.String }) {} + + expect(converter.convert(toStandardSchema(Planet), direction)).toEqual([ + { + $ref: '#/$defs/PlanetEncoded', + $defs: { + PlanetEncoded: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: true, + }, + }, }, - }, - }, false]) - }) + false, + ]) + }, + ) it('resolves recursion into a $ref', () => { interface Category { @@ -68,25 +105,30 @@ describe('effectSchemaToJsonSchemaConverter', () => { children: Schema.Array(Schema.suspend((): Schema.Codec => CategorySchema)), }).annotate({ identifier: 'Category' }) - expect(converter.convert(toStandardSchema(CategorySchema), 'input')).toEqual([{ - $ref: '#/$defs/Category', - $defs: { - Category: { - type: 'object', - properties: { - name: { type: 'string' }, - children: { type: 'array', items: { $ref: '#/$defs/Category' } }, + expect(converter.convert(toStandardSchema(CategorySchema), 'input')).toEqual([ + { + $ref: '#/$defs/Category', + $defs: { + Category: { + type: 'object', + properties: { + name: { type: 'string' }, + children: { type: 'array', items: { $ref: '#/$defs/Category' } }, + }, + required: ['name', 'children'], + additionalProperties: true, }, - required: ['name', 'children'], - additionalProperties: true, }, }, - }, false]) + false, + ]) }) }) describe('optionality', () => { - const Defaulted = Schema.UndefinedOr(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed('fallback'))) + const Defaulted = Schema.UndefinedOr(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed('fallback')), + ) it.each([ ['defaulted input schema', Defaulted, 'input', true], @@ -96,7 +138,10 @@ describe('effectSchemaToJsonSchemaConverter', () => { ['required input schema', Schema.String, 'input', false], ['required output schema', Schema.String, 'output', false], ] as const)('marks %s correctly', (_, schema, direction, optional) => { - expect(converter.convert(toStandardSchema(schema), direction)).toEqual([expect.any(Object), optional]) + expect(converter.convert(toStandardSchema(schema), direction)).toEqual([ + expect.any(Object), + optional, + ]) }) it('marks as required if standard validation throws', () => { @@ -114,7 +159,9 @@ describe('effectSchemaToJsonSchemaConverter', () => { // Effect cannot represent symbol property names in JSON Schema const schema = toStandardSchema(Schema.Struct({ [Symbol.for('key')]: Schema.String })) - expect(() => converter.convert(schema, 'input')).toThrow('Objects property names must be strings') + expect(() => converter.convert(schema, 'input')).toThrow( + 'Objects property names must be strings', + ) }) describe('cache option', () => { diff --git a/packages/effect/src/converter.ts b/packages/effect/src/converter.ts index 59a088fe81..08855b43f7 100644 --- a/packages/effect/src/converter.ts +++ b/packages/effect/src/converter.ts @@ -1,5 +1,9 @@ import type { AnySchema } from '@orpc/contract' -import type { JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' +import type { + JsonSchema, + JsonSchemaConverter, + JsonSchemaConverterDirection, +} from '@orpc/json-schema' import { isStandardSchemaOptional } from '@orpc/json-schema' import { Schema as EffectSchema } from 'effect' @@ -25,7 +29,14 @@ export interface EffectSchemaToJsonSchemaConverterOptions extends EffectSchema.T */ export class EffectSchemaToJsonSchemaConverter implements JsonSchemaConverter { private readonly toJsonSchemaOptions: EffectSchema.ToJsonSchemaOptions - private readonly cache: undefined | { [d in JsonSchemaConverterDirection]: WeakMap } + private readonly cache: + | undefined + | { + [d in JsonSchemaConverterDirection]: WeakMap< + EffectSchema.Top, + [jsonSchema: JsonSchema, optional: boolean] + > + } constructor({ cache, ...toJsonSchemaOptions }: EffectSchemaToJsonSchemaConverterOptions = {}) { this.toJsonSchemaOptions = toJsonSchemaOptions @@ -39,7 +50,10 @@ export class EffectSchemaToJsonSchemaConverter implements JsonSchemaConverter { return schema?.['~standard'].vendor === 'effect' && EffectSchema.isSchema(schema) } - convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] { + convert( + schema: AnySchema | undefined, + direction: JsonSchemaConverterDirection, + ): [jsonSchema: JsonSchema, optional: boolean] { const effectSchema = schema as EffectSchema.Top & AnySchema if (this.cache) { @@ -58,16 +72,24 @@ export class EffectSchemaToJsonSchemaConverter implements JsonSchemaConverter { return result } - private convertUncached(schema: EffectSchema.Top & AnySchema, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] { + private convertUncached( + schema: EffectSchema.Top & AnySchema, + direction: JsonSchemaConverterDirection, + ): [jsonSchema: JsonSchema, optional: boolean] { return [this.convertEffect(schema, direction), isStandardSchemaOptional(schema, direction)] } - private convertEffect(schema: EffectSchema.Top, direction: JsonSchemaConverterDirection): JsonSchema { + private convertEffect( + schema: EffectSchema.Top, + direction: JsonSchemaConverterDirection, + ): JsonSchema { const { schema: jsonSchema, definitions } = EffectSchema.toJsonSchemaDocument( direction === 'input' ? schema : EffectSchema.toType(schema), this.toJsonSchemaOptions, ) - return (Object.keys(definitions).length > 0 ? { ...jsonSchema, $defs: definitions } : jsonSchema) as JsonSchema + return ( + Object.keys(definitions).length > 0 ? { ...jsonSchema, $defs: definitions } : jsonSchema + ) as JsonSchema } } diff --git a/packages/effect/src/error.test-d.ts b/packages/effect/src/error.test-d.ts index 05c96d8680..5542e17183 100644 --- a/packages/effect/src/error.test-d.ts +++ b/packages/effect/src/error.test-d.ts @@ -1,7 +1,8 @@ import type { AnyORPCError, ORPCErrorCode } from '@orpc/client' import { ORPCError } from '@orpc/client' import { Effect } from 'effect' -import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from './error' + +import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from './error.ts' class Service1 { declare id: 'Service1' @@ -24,26 +25,37 @@ const effectWithHidden = {} as Effect.Effect< > const effectWithAny = Effect.fail(new Error('boom') as unknown).pipe( - Effect.mapError(error => error instanceof ORPCError ? error : new ORPCError('INTERNAL_SERVER_ERROR')), + Effect.mapError((error) => + error instanceof ORPCError ? error : new ORPCError('INTERNAL_SERVER_ERROR'), + ), ) const effectWithWide = {} as Effect.Effect<'output', ORPCError> const effectWithString = {} as Effect.Effect<'output', ORPCError> -const effectWithUnion = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND' | 'CONFLICT', { id: string }>> +const effectWithUnion = {} as Effect.Effect< + 'output', + ORPCError<'NOT_FOUND' | 'CONFLICT', { id: string }> +> describe('catchORPCError', () => { it('catches every ORPCError and excludes them from the error channel (data-last)', () => { - const recovered = effect.pipe(catchORPCError((error) => { - expectTypeOf(error).toEqualTypeOf | ORPCError<'CONFLICT', number>>() - return Effect.succeed('recovered' as const) - })) + const recovered = effect.pipe( + catchORPCError((error) => { + expectTypeOf(error).toEqualTypeOf< + ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number> + >() + return Effect.succeed('recovered' as const) + }), + ) expectTypeOf(recovered).toEqualTypeOf>() }) it('catches every ORPCError and excludes them from the error channel (data-first)', () => { const recovered = catchORPCError(effect, (error) => { - expectTypeOf(error).toEqualTypeOf | ORPCError<'CONFLICT', number>>() + expectTypeOf(error).toEqualTypeOf< + ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number> + >() return Effect.succeed('recovered' as const) }) @@ -52,58 +64,82 @@ describe('catchORPCError', () => { it('merges handler error and requirement channels into the result', () => { const recovered = effect.pipe( - catchORPCError(() => ({} as Effect.Effect<'recovered', RangeError, Service2>)), + catchORPCError(() => ({}) as Effect.Effect<'recovered', RangeError, Service2>), ) - expectTypeOf(recovered).toEqualTypeOf>() + expectTypeOf(recovered).toEqualTypeOf< + Effect.Effect<'output' | 'recovered', RangeError, Service1 | Service2> + >() }) it('also receives hidden ORPCErrors, with unknown code and data', () => { - const recovered = effectWithHidden.pipe(catchORPCError((error) => { - expectTypeOf(error).toEqualTypeOf | ORPCError>() - expectTypeOf(error.data).toEqualTypeOf() - return Effect.succeed('recovered' as const) - })) + const recovered = effectWithHidden.pipe( + catchORPCError((error) => { + expectTypeOf(error).toEqualTypeOf< + ORPCError<'NOT_FOUND', { id: string }> | ORPCError + >() + expectTypeOf(error.data).toEqualTypeOf() + return Effect.succeed('recovered' as const) + }), + ) expectTypeOf(recovered).toEqualTypeOf>() }) it('treats failure types that can hold an ORPCError as possibly hiding one', () => { - const withTypeError = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND', { id: string }> | TypeError> - - void withTypeError.pipe(catchORPCError((error) => { - expectTypeOf(error).toEqualTypeOf | ORPCError>() - expectTypeOf(error.data).toEqualTypeOf() - return Effect.succeed('recovered' as const) - })) + const withTypeError = {} as Effect.Effect< + 'output', + ORPCError<'NOT_FOUND', { id: string }> | TypeError + > + + void withTypeError.pipe( + catchORPCError((error) => { + expectTypeOf(error).toEqualTypeOf< + ORPCError<'NOT_FOUND', { id: string }> | ORPCError + >() + expectTypeOf(error.data).toEqualTypeOf() + return Effect.succeed('recovered' as const) + }), + ) }) it('does not treat failure types that cannot hold an ORPCError as hidden', () => { - const withString = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND', { id: string }> | 'boom'> + const withString = {} as Effect.Effect< + 'output', + ORPCError<'NOT_FOUND', { id: string }> | 'boom' + > - void withString.pipe(catchORPCError((error) => { - expectTypeOf(error).toEqualTypeOf>() - expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() - return Effect.succeed('recovered' as const) - })) + void withString.pipe( + catchORPCError((error) => { + expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() + return Effect.succeed('recovered' as const) + }), + ) const onlyString = {} as Effect.Effect<'output', 'boom'> - void onlyString.pipe(catchORPCError((error) => { - expectTypeOf(error).toEqualTypeOf() - return Effect.succeed('recovered' as const) - })) + void onlyString.pipe( + catchORPCError((error) => { + expectTypeOf(error).toEqualTypeOf() + return Effect.succeed('recovered' as const) + }), + ) }) }) describe('catchORPCErrorCode', () => { it('catches ORPCErrors with a matching code and excludes them from the error channel (data-last)', () => { - const recovered = effect.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed('recovered' as const) - })) + const recovered = effect.pipe( + catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('recovered' as const) + }), + ) - expectTypeOf(recovered).toEqualTypeOf, Service1>>() + expectTypeOf(recovered).toEqualTypeOf< + Effect.Effect<'output' | 'recovered', ORPCError<'CONFLICT', number>, Service1> + >() }) it('catches ORPCErrors with a matching code and excludes them from the error channel (data-first)', () => { @@ -112,28 +148,39 @@ describe('catchORPCErrorCode', () => { return Effect.succeed('recovered' as const) }) - expectTypeOf(recovered).toEqualTypeOf, Service1>>() + expectTypeOf(recovered).toEqualTypeOf< + Effect.Effect<'output' | 'recovered', ORPCError<'NOT_FOUND', { id: string }>, Service1> + >() }) it('merges handler error and requirement channels into the result', () => { const recovered = effect.pipe( - catchORPCErrorCode('NOT_FOUND', () => ({} as Effect.Effect<'recovered', RangeError, Service2>)), + catchORPCErrorCode( + 'NOT_FOUND', + () => ({}) as Effect.Effect<'recovered', RangeError, Service2>, + ), ) expectTypeOf(recovered).toEqualTypeOf< - Effect.Effect<'output' | 'recovered', ORPCError<'CONFLICT', number> | RangeError, Service1 | Service2> + Effect.Effect< + 'output' | 'recovered', + ORPCError<'CONFLICT', number> | RangeError, + Service1 | Service2 + > >() }) it('supports custom error codes', () => { const custom = {} as Effect.Effect<'output', ORPCError<'__CUSTOM__', undefined>> - const recovered = custom.pipe(catchORPCErrorCode('__CUSTOM__', (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed('recovered' as const) - })) + const recovered = custom.pipe( + catchORPCErrorCode('__CUSTOM__', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('recovered' as const) + }), + ) - expectTypeOf(recovered).toEqualTypeOf>() + expectTypeOf(recovered).toEqualTypeOf>() }) it('suggests and restricts the code to those present in the error channel', () => { @@ -148,43 +195,60 @@ describe('catchORPCErrorCode', () => { }) it('widens data to unknown on declared codes when the channel can hold hidden ORPCErrors', () => { - const recovered = effectWithHidden.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { - expectTypeOf(error).toEqualTypeOf | ORPCError<'NOT_FOUND', unknown>>() - expectTypeOf(error.data).toEqualTypeOf() - return Effect.succeed('recovered' as const) - })) + const recovered = effectWithHidden.pipe( + catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf< + ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'NOT_FOUND', unknown> + >() + expectTypeOf(error.data).toEqualTypeOf() + return Effect.succeed('recovered' as const) + }), + ) expectTypeOf(recovered).toEqualTypeOf>() }) it('accepts undeclared codes when the channel can hold hidden ORPCErrors', () => { - const recovered = effectWithHidden.pipe(catchORPCErrorCode('CONFLICT', (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed('recovered' as const) - })) + const recovered = effectWithHidden.pipe( + catchORPCErrorCode('CONFLICT', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('recovered' as const) + }), + ) expectTypeOf(recovered).toEqualTypeOf< - Effect.Effect<'output' | 'recovered', ORPCError<'NOT_FOUND', { id: string }> | Error, Service1> + Effect.Effect< + 'output' | 'recovered', + ORPCError<'NOT_FOUND', { id: string }> | Error, + Service1 + > >() }) it('unknown error channels can also hold hidden ORPCErrors', () => { const withUnknown = {} as Effect.Effect<'output', unknown> - void withUnknown.pipe(catchORPCErrorCode('CONFLICT', (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed('recovered' as const) - })) + void withUnknown.pipe( + catchORPCErrorCode('CONFLICT', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('recovered' as const) + }), + ) }) it('keeps data typed and codes restricted when the channel cannot hold hidden ORPCErrors', () => { - const withString = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND', { id: string }> | 'boom'> + const withString = {} as Effect.Effect< + 'output', + ORPCError<'NOT_FOUND', { id: string }> | 'boom' + > - void withString.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { - expectTypeOf(error).toEqualTypeOf>() - expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() - return Effect.succeed('recovered' as const) - })) + void withString.pipe( + catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() + return Effect.succeed('recovered' as const) + }), + ) // @ts-expect-error - BAD_GATEWAY is not present and the channel cannot hold hidden ORPCErrors void withString.pipe(catchORPCErrorCode('BAD_GATEWAY', () => Effect.succeed('recovered'))) @@ -193,37 +257,48 @@ describe('catchORPCErrorCode', () => { it('keeps AnyORPCError in the error channel and narrows the handler to the caught code', () => { expectTypeOf(effectWithAny).toEqualTypeOf>() - const recovered = effectWithAny.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed(error.code) - })) + const recovered = effectWithAny.pipe( + catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed(error.code) + }), + ) expectTypeOf(recovered).toEqualTypeOf>() }) it('narrows wide codes to the caught code and keeps them in the error channel', () => { - const recoveredWide = effectWithWide.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed(error.code) - })) + const recoveredWide = effectWithWide.pipe( + catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed(error.code) + }), + ) - expectTypeOf(recoveredWide).toEqualTypeOf>>() + expectTypeOf(recoveredWide).toEqualTypeOf< + Effect.Effect<'output' | 'NOT_FOUND', ORPCError> + >() const recoveredString = catchORPCErrorCode(effectWithString, 'NOT_FOUND', (error) => { expectTypeOf(error).toEqualTypeOf>() return Effect.succeed(error.code) }) - expectTypeOf(recoveredString).toEqualTypeOf>>() + expectTypeOf(recoveredString).toEqualTypeOf< + Effect.Effect<'output' | 'NOT_FOUND', ORPCError> + >() }) it('narrows union codes to the caught code and keeps them in the error channel', () => { - const recovered = effectWithUnion.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed(error.data.id) - })) + const recovered = effectWithUnion.pipe( + catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed(error.data.id) + }), + ) expectTypeOf(recovered).toEqualTypeOf< + // oxlint-disable-next-line typescript/no-redundant-type-constituents Effect.Effect<'output' | string, ORPCError<'NOT_FOUND' | 'CONFLICT', { id: string }>> >() }) @@ -231,22 +306,38 @@ describe('catchORPCErrorCode', () => { it('keeps errors in the error channel when the code is not a single literal', () => { const code = {} as 'NOT_FOUND' | 'CONFLICT' - const recoveredUnion = effect.pipe(catchORPCErrorCode(code, (error) => { - expectTypeOf(error).toEqualTypeOf | ORPCError<'CONFLICT', number>>() - return Effect.succeed('recovered' as const) - })) + const recoveredUnion = effect.pipe( + catchORPCErrorCode(code, (error) => { + expectTypeOf(error).toEqualTypeOf< + ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number> + >() + return Effect.succeed('recovered' as const) + }), + ) expectTypeOf(recoveredUnion).toEqualTypeOf< - Effect.Effect<'output' | 'recovered', ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>, Service1> + Effect.Effect< + 'output' | 'recovered', + ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>, + Service1 + > >() - const recoveredString = effectWithHidden.pipe(catchORPCErrorCode({} as string, (error) => { - expectTypeOf(error).toEqualTypeOf | ORPCError>() - return Effect.succeed('recovered' as const) - })) + const recoveredString = effectWithHidden.pipe( + catchORPCErrorCode({} as string, (error) => { + expectTypeOf(error).toEqualTypeOf< + ORPCError<'NOT_FOUND', { id: string }> | ORPCError + >() + return Effect.succeed('recovered' as const) + }), + ) expectTypeOf(recoveredString).toEqualTypeOf< - Effect.Effect<'output' | 'recovered', ORPCError<'NOT_FOUND', { id: string }> | Error, Service1> + Effect.Effect< + 'output' | 'recovered', + ORPCError<'NOT_FOUND', { id: string }> | Error, + Service1 + > >() }) @@ -255,31 +346,42 @@ describe('catchORPCErrorCode', () => { declare readonly extra: 'extra' } - const withSubclass = {} as Effect.Effect<'output', NotFoundError | ORPCError<'CONFLICT', number>> + const withSubclass = {} as Effect.Effect< + 'output', + NotFoundError | ORPCError<'CONFLICT', number> + > - const recovered = withSubclass.pipe(catchORPCErrorCode('NOT_FOUND', (error) => { - expectTypeOf(error).toEqualTypeOf() - return Effect.succeed(error.extra) - })) + const recovered = withSubclass.pipe( + catchORPCErrorCode('NOT_FOUND', (error) => { + expectTypeOf(error).toEqualTypeOf() + return Effect.succeed(error.extra) + }), + ) - expectTypeOf(recovered).toEqualTypeOf>>() + expectTypeOf(recovered).toEqualTypeOf< + Effect.Effect<'output' | 'extra', ORPCError<'CONFLICT', number>> + >() }) }) describe('catchORPCErrorCodes', () => { it('narrows each handler and excludes handled codes from the error channel (data-last)', () => { - const recovered = effect.pipe(catchORPCErrorCodes({ - NOT_FOUND: (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed('nf' as const) - }, - CONFLICT: (error) => { - expectTypeOf(error).toEqualTypeOf>() - return {} as Effect.Effect<'cf', RangeError, Service2> - }, - })) + const recovered = effect.pipe( + catchORPCErrorCodes({ + NOT_FOUND: (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('nf' as const) + }, + CONFLICT: (error) => { + expectTypeOf(error).toEqualTypeOf>() + return {} as Effect.Effect<'cf', RangeError, Service2> + }, + }), + ) - expectTypeOf(recovered).toEqualTypeOf>() + expectTypeOf(recovered).toEqualTypeOf< + Effect.Effect<'output' | 'nf' | 'cf', RangeError, Service1 | Service2> + >() }) it('keeps unhandled codes in the error channel (data-first)', () => { @@ -290,7 +392,9 @@ describe('catchORPCErrorCodes', () => { }, }) - expectTypeOf(recovered).toEqualTypeOf, Service1>>() + expectTypeOf(recovered).toEqualTypeOf< + Effect.Effect<'output' | 'nf', ORPCError<'CONFLICT', number>, Service1> + >() }) it('suggests and restricts keys to the codes present in the error channel', () => { @@ -302,55 +406,68 @@ describe('catchORPCErrorCodes', () => { }) it('widens data to unknown and accepts undeclared keys when the channel can hold hidden ORPCErrors', () => { - const recovered = effectWithHidden.pipe(catchORPCErrorCodes({ - NOT_FOUND: (error) => { - expectTypeOf(error.data).toEqualTypeOf() - return Effect.succeed('nf' as const) - }, - CONFLICT: (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed('cf' as const) - }, - })) + const recovered = effectWithHidden.pipe( + catchORPCErrorCodes({ + NOT_FOUND: (error) => { + expectTypeOf(error.data).toEqualTypeOf() + return Effect.succeed('nf' as const) + }, + CONFLICT: (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('cf' as const) + }, + }), + ) expectTypeOf(recovered).toEqualTypeOf>() }) it('keeps data typed and keys restricted when the channel cannot hold hidden ORPCErrors', () => { - const withString = {} as Effect.Effect<'output', ORPCError<'NOT_FOUND', { id: string }> | 'boom'> - - void withString.pipe(catchORPCErrorCodes({ - NOT_FOUND: (error) => { - expectTypeOf(error).toEqualTypeOf>() - expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() - return Effect.succeed('recovered' as const) - }, - })) + const withString = {} as Effect.Effect< + 'output', + ORPCError<'NOT_FOUND', { id: string }> | 'boom' + > + + void withString.pipe( + catchORPCErrorCodes({ + NOT_FOUND: (error) => { + expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() + return Effect.succeed('recovered' as const) + }, + }), + ) // @ts-expect-error - BAD_GATEWAY is not present and the channel cannot hold hidden ORPCErrors void withString.pipe(catchORPCErrorCodes({ BAD_GATEWAY: () => Effect.succeed('recovered') })) }) it('keeps AnyORPCError in the error channel', () => { - const recovered = effectWithAny.pipe(catchORPCErrorCodes({ - NOT_FOUND: (error) => { - expectTypeOf(error).toEqualTypeOf() - return Effect.succeed('nf' as const) - }, - })) + const recovered = effectWithAny.pipe( + catchORPCErrorCodes({ + NOT_FOUND: (error) => { + expectTypeOf(error).toEqualTypeOf() + return Effect.succeed('nf' as const) + }, + }), + ) expectTypeOf(recovered).toEqualTypeOf>() }) it('types handlers of wide codes and keeps them in the error channel', () => { - const recoveredWide = effectWithWide.pipe(catchORPCErrorCodes({ - NOT_FOUND: (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed('nf' as const) - }, - })) + const recoveredWide = effectWithWide.pipe( + catchORPCErrorCodes({ + NOT_FOUND: (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('nf' as const) + }, + }), + ) - expectTypeOf(recoveredWide).toEqualTypeOf>>() + expectTypeOf(recoveredWide).toEqualTypeOf< + Effect.Effect<'output' | 'nf', ORPCError> + >() const recoveredString = catchORPCErrorCodes(effectWithString, { NOT_FOUND: (error) => { @@ -359,54 +476,68 @@ describe('catchORPCErrorCodes', () => { }, }) - expectTypeOf(recoveredString).toEqualTypeOf>>() + expectTypeOf(recoveredString).toEqualTypeOf< + Effect.Effect<'output' | 'nf', ORPCError> + >() }) it('types handlers of undeclared keys when the channel can hold hidden ORPCErrors', () => { - void effectWithHidden.pipe(catchORPCErrorCodes({ - __CUSTOM__: (error) => { - expectTypeOf(error).toExtend() - expectTypeOf(error.code).toExtend() - expectTypeOf(error.data).toEqualTypeOf() - return Effect.succeed('recovered' as const) - }, - })) + void effectWithHidden.pipe( + catchORPCErrorCodes({ + __CUSTOM__: (error) => { + expectTypeOf(error).toExtend() + expectTypeOf(error.code).toExtend() + expectTypeOf(error.data).toEqualTypeOf() + return Effect.succeed('recovered' as const) + }, + }), + ) }) it('narrows union codes and excludes them only once every code is handled', () => { - const recoveredOne = effectWithUnion.pipe(catchORPCErrorCodes({ - NOT_FOUND: (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed('nf' as const) - }, - })) + const recoveredOne = effectWithUnion.pipe( + catchORPCErrorCodes({ + NOT_FOUND: (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('nf' as const) + }, + }), + ) expectTypeOf(recoveredOne).toEqualTypeOf< Effect.Effect<'output' | 'nf', ORPCError<'NOT_FOUND' | 'CONFLICT', { id: string }>> >() - const recoveredAll = effectWithUnion.pipe(catchORPCErrorCodes({ - NOT_FOUND: () => Effect.succeed('nf' as const), - CONFLICT: (error) => { - expectTypeOf(error).toEqualTypeOf>() - return Effect.succeed('cf' as const) - }, - })) + const recoveredAll = effectWithUnion.pipe( + catchORPCErrorCodes({ + NOT_FOUND: () => Effect.succeed('nf' as const), + CONFLICT: (error) => { + expectTypeOf(error).toEqualTypeOf>() + return Effect.succeed('cf' as const) + }, + }), + ) - expectTypeOf(recoveredAll).toEqualTypeOf>() + expectTypeOf(recoveredAll).toEqualTypeOf>() }) it('keeps codes whose handler can be undefined in the error channel', () => { - const recoveredUndefined = effect.pipe(catchORPCErrorCodes({ - NOT_FOUND: undefined, - CONFLICT: () => Effect.succeed('cf' as const), - })) + const recoveredUndefined = effect.pipe( + catchORPCErrorCodes({ + NOT_FOUND: undefined, + CONFLICT: () => Effect.succeed('cf' as const), + }), + ) expectTypeOf(recoveredUndefined).toEqualTypeOf< Effect.Effect<'output' | 'cf', ORPCError<'NOT_FOUND', { id: string }>, Service1> >() - const maybeHandler = {} as ((error: ORPCError<'NOT_FOUND', { id: string }>) => Effect.Effect<'nf', RangeError, Service2>) | undefined + const maybeHandler = {} as + | (( + error: ORPCError<'NOT_FOUND', { id: string }>, + ) => Effect.Effect<'nf', RangeError, Service2>) + | undefined const recoveredMaybe = effect.pipe(catchORPCErrorCodes({ NOT_FOUND: maybeHandler })) @@ -418,12 +549,18 @@ describe('catchORPCErrorCodes', () => { > >() - const optionalCases = {} as { NOT_FOUND?: (error: ORPCError<'NOT_FOUND', { id: string }>) => Effect.Effect<'nf'> } + const optionalCases = {} as { + NOT_FOUND?: (error: ORPCError<'NOT_FOUND', { id: string }>) => Effect.Effect<'nf'> + } const recoveredOptional = effect.pipe(catchORPCErrorCodes(optionalCases)) expectTypeOf(recoveredOptional).toEqualTypeOf< - Effect.Effect<'output' | 'nf', ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>, Service1> + Effect.Effect< + 'output' | 'nf', + ORPCError<'NOT_FOUND', { id: string }> | ORPCError<'CONFLICT', number>, + Service1 + > >() }) }) diff --git a/packages/effect/src/error.test.ts b/packages/effect/src/error.test.ts index 8b3f6ea415..3a46742ead 100644 --- a/packages/effect/src/error.test.ts +++ b/packages/effect/src/error.test.ts @@ -1,20 +1,20 @@ import { ORPCError } from '@orpc/client' import { Cause, Effect, Exit } from 'effect' -import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from './error' + +import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from './error.ts' describe('catchORPCError', () => { it('catches any ORPCError failure (data-last)', async () => { const handled = Effect.fail(new ORPCError('NOT_FOUND', { data: 'data' })).pipe( - catchORPCError(error => Effect.succeed(`caught:${error.code}`)), + catchORPCError((error) => Effect.succeed(`caught:${error.code}`)), ) await expect(Effect.runPromise(handled)).resolves.toEqual('caught:NOT_FOUND') }) it('catches any ORPCError failure (data-first)', async () => { - const handled = catchORPCError( - Effect.fail(new ORPCError('CONFLICT')), - error => Effect.succeed(`caught:${error.code}`), + const handled = catchORPCError(Effect.fail(new ORPCError('CONFLICT')), (error) => + Effect.succeed(`caught:${error.code}`), ) await expect(Effect.runPromise(handled)).resolves.toEqual('caught:CONFLICT') @@ -43,7 +43,9 @@ describe('catchORPCError', () => { it('does not touch successful effects', async () => { const handler = vi.fn(() => Effect.succeed('caught')) - await expect(Effect.runPromise(catchORPCError(Effect.succeed('output'), handler))).resolves.toEqual('output') + await expect( + Effect.runPromise(catchORPCError(Effect.succeed('output'), handler)), + ).resolves.toEqual('output') expect(handler).not.toHaveBeenCalled() }) @@ -51,9 +53,7 @@ describe('catchORPCError', () => { const error = new Error('__TEST__') const exit = await Effect.runPromiseExit( - Effect.fail(new ORPCError('NOT_FOUND')).pipe( - catchORPCError(() => Effect.fail(error)), - ), + Effect.fail(new ORPCError('NOT_FOUND')).pipe(catchORPCError(() => Effect.fail(error))), ) expect(exit).toEqual(Exit.fail(error)) @@ -63,7 +63,7 @@ describe('catchORPCError', () => { describe('catchORPCErrorCode', () => { it('catches ORPCError failures with a matching code (data-last)', async () => { const handled = Effect.fail(new ORPCError('NOT_FOUND', { data: 'data' })).pipe( - catchORPCErrorCode('NOT_FOUND', error => Effect.succeed(`caught:${error.data}`)), + catchORPCErrorCode('NOT_FOUND', (error) => Effect.succeed(`caught:${error.data}`)), ) await expect(Effect.runPromise(handled)).resolves.toEqual('caught:data') @@ -73,14 +73,16 @@ describe('catchORPCErrorCode', () => { const handled = catchORPCErrorCode( Effect.fail(new ORPCError('CONFLICT', { data: 'data' })), 'CONFLICT', - error => Effect.succeed(`caught:${error.data}`), + (error) => Effect.succeed(`caught:${error.data}`), ) await expect(Effect.runPromise(handled)).resolves.toEqual('caught:data') }) it('does not catch ORPCError failures with a different code', async () => { - const error = new ORPCError('CONFLICT') as ORPCError<'CONFLICT', undefined> | ORPCError<'NOT_FOUND', undefined> + const error = new ORPCError('CONFLICT') as + | ORPCError<'CONFLICT', undefined> + | ORPCError<'NOT_FOUND', undefined> const handler = vi.fn(() => Effect.succeed('caught')) const exit = await Effect.runPromiseExit( @@ -92,7 +94,9 @@ describe('catchORPCErrorCode', () => { }) it('does not catch non-ORPCError failures, even with a matching code property', async () => { - const error = Object.assign(new Error('__TEST__'), { code: 'NOT_FOUND' }) as Error | ORPCError<'NOT_FOUND', undefined> + const error = Object.assign(new Error('__TEST__'), { code: 'NOT_FOUND' }) as + | Error + | ORPCError<'NOT_FOUND', undefined> const handler = vi.fn(() => Effect.succeed('caught')) const exit = await Effect.runPromiseExit( @@ -105,7 +109,10 @@ describe('catchORPCErrorCode', () => { it('does not touch successful effects', async () => { const handler = vi.fn(() => Effect.succeed('caught')) - const effect = Effect.succeed('output') as Effect.Effect> + const effect = Effect.succeed('output') as Effect.Effect< + string, + ORPCError<'NOT_FOUND', undefined> + > await expect( Effect.runPromise(catchORPCErrorCode(effect, 'NOT_FOUND', handler)), @@ -129,11 +136,13 @@ describe('catchORPCErrorCode', () => { describe('catchORPCErrorCodes', () => { it('catches ORPCError failures with the matching handler (data-last)', async () => { const conflictHandler = vi.fn(() => Effect.succeed('caught:conflict')) - const error = new ORPCError('NOT_FOUND', { data: 'data' }) as ORPCError<'NOT_FOUND', string> | ORPCError<'CONFLICT', undefined> + const error = new ORPCError('NOT_FOUND', { data: 'data' }) as + | ORPCError<'NOT_FOUND', string> + | ORPCError<'CONFLICT', undefined> const handled = Effect.fail(error).pipe( catchORPCErrorCodes({ - NOT_FOUND: error => Effect.succeed(`caught:${error.data}`), + NOT_FOUND: (error) => Effect.succeed(`caught:${error.data}`), CONFLICT: conflictHandler, }), ) @@ -143,16 +152,17 @@ describe('catchORPCErrorCodes', () => { }) it('catches ORPCError failures with the matching handler (data-first)', async () => { - const handled = catchORPCErrorCodes( - Effect.fail(new ORPCError('CONFLICT', { data: 'data' })), - { CONFLICT: error => Effect.succeed(`caught:${error.data}`) }, - ) + const handled = catchORPCErrorCodes(Effect.fail(new ORPCError('CONFLICT', { data: 'data' })), { + CONFLICT: (error) => Effect.succeed(`caught:${error.data}`), + }) await expect(Effect.runPromise(handled)).resolves.toEqual('caught:data') }) it('does not catch ORPCError failures without a matching handler', async () => { - const error = new ORPCError('CONFLICT') as ORPCError<'CONFLICT', undefined> | ORPCError<'NOT_FOUND', undefined> + const error = new ORPCError('CONFLICT') as + | ORPCError<'CONFLICT', undefined> + | ORPCError<'NOT_FOUND', undefined> const handler = vi.fn(() => Effect.succeed('caught')) const exit = await Effect.runPromiseExit( @@ -174,7 +184,9 @@ describe('catchORPCErrorCodes', () => { }) it('does not catch non-ORPCError failures, even with a matching code property', async () => { - const error = Object.assign(new Error('__TEST__'), { code: 'NOT_FOUND' }) as Error | ORPCError<'NOT_FOUND', undefined> + const error = Object.assign(new Error('__TEST__'), { code: 'NOT_FOUND' }) as + | Error + | ORPCError<'NOT_FOUND', undefined> const handler = vi.fn(() => Effect.succeed('caught')) const exit = await Effect.runPromiseExit( @@ -187,7 +199,10 @@ describe('catchORPCErrorCodes', () => { it('does not touch successful effects', async () => { const handler = vi.fn(() => Effect.succeed('caught')) - const effect = Effect.succeed('output') as Effect.Effect> + const effect = Effect.succeed('output') as Effect.Effect< + string, + ORPCError<'NOT_FOUND', undefined> + > await expect( Effect.runPromise(catchORPCErrorCodes(effect, { NOT_FOUND: handler })), diff --git a/packages/effect/src/error.ts b/packages/effect/src/error.ts index 3523c5e87e..d1faccaf05 100644 --- a/packages/effect/src/error.ts +++ b/packages/effect/src/error.ts @@ -1,47 +1,65 @@ import type { AnyORPCError, ORPCErrorCode } from '@orpc/client' -import type { Types } from 'effect' import { ORPCError } from '@orpc/client' import { getOwn } from '@orpc/shared' +import type { Types } from 'effect' import { Effect, Function } from 'effect' type AnyEffect = Effect.Effect -type ORPCErrorsOf = E extends AnyORPCError ? E : AnyORPCError extends E ? ORPCError : never +type ORPCErrorsOf = E extends AnyORPCError + ? E + : AnyORPCError extends E + ? ORPCError + : never type ORPCErrorCodeOf = ORPCErrorsOf['code'] -type ORPCErrorWithCode = E extends ORPCError - ? [Exclude] extends [never] +type ORPCErrorWithCode = + E extends ORPCError + ? [Exclude] extends [never] ? E - : [UCode & TCode] extends [never] ? never : ORPCError - : AnyORPCError extends E ? ORPCError : never + : [UCode & TCode] extends [never] + ? never + : ORPCError + : AnyORPCError extends E + ? ORPCError + : never -type ExcludeORPCErrorCode = E extends ORPCError - ? [Exclude] extends [never] ? never : E - : E +type ExcludeORPCErrorCode = + E extends ORPCError ? ([Exclude] extends [never] ? never : E) : E -type LiteralORPCErrorCode = Types.IsUnion extends true - ? never - : Record extends Record ? never : TCode +type LiteralORPCErrorCode = + Types.IsUnion extends true + ? never + : Record extends Record + ? never + : TCode -type ORPCErrorCodeCases = Types.NoExcessProperties<{ - [K in ORPCErrorCodeOf]?: Record extends Record - ? { bivarianceHack(error: ORPCErrorWithCode): AnyEffect }['bivarianceHack'] - : (error: ORPCErrorWithCode) => AnyEffect -}, TCases> +type ORPCErrorCodeCases = Types.NoExcessProperties< + { + [K in ORPCErrorCodeOf]?: Record extends Record + ? { bivarianceHack(error: ORPCErrorWithCode): AnyEffect }['bivarianceHack'] + : (error: ORPCErrorWithCode) => AnyEffect + }, + TCases +> /** * The codes `TCases` surely handles: required keys with a function value. */ type HandledORPCErrorCodes = { [K in keyof TCases]-?: TCases[K] extends (...args: any[]) => any ? K : never -}[Types.RequiredKeys] & string +}[Types.RequiredKeys] & + string -type ORPCErrorCodeCasesEffect = ReturnType any>> +type ORPCErrorCodeCasesEffect = ReturnType< + Extract any> +> type CatchORPCErrorCodesEffect = Effect.Effect< A | Effect.Success>, - ExcludeORPCErrorCode> | Effect.Error>, + | ExcludeORPCErrorCode> + | Effect.Error>, R | Effect.Services> > @@ -68,19 +86,15 @@ type CatchORPCErrorCodesEffect = Effect.Effect< export const catchORPCError: { ( f: (error: ORPCErrorsOf) => Effect.Effect, - ): (self: Effect.Effect) => Effect.Effect, R | R2> + ): ( + self: Effect.Effect, + ) => Effect.Effect, R | R2> ( self: Effect.Effect, f: (error: ORPCErrorsOf) => Effect.Effect, ): Effect.Effect, R | R2> -} = /* @__PURE__ */ Function.dual( - 2, - ( - self: AnyEffect, - f: (error: AnyORPCError) => AnyEffect, - ) => self.pipe( - Effect.catchIf((error): error is AnyORPCError => error instanceof ORPCError, f), - ), +} = /* @__PURE__ */ Function.dual(2, (self: AnyEffect, f: (error: AnyORPCError) => AnyEffect) => + self.pipe(Effect.catchIf((error): error is AnyORPCError => error instanceof ORPCError, f)), ) /** @@ -111,7 +125,9 @@ export const catchORPCErrorCode: { , E, A2, E2, R2>( code: TCode, f: (error: ORPCErrorWithCode) => Effect.Effect, - ): (self: Effect.Effect) => Effect.Effect>, R | R2> + ): ( + self: Effect.Effect, + ) => Effect.Effect>, R | R2> , A2, E2, R2>( self: Effect.Effect, code: TCode, @@ -119,13 +135,13 @@ export const catchORPCErrorCode: { ): Effect.Effect>, R | R2> } = /* @__PURE__ */ Function.dual( 3, - ( - self: AnyEffect, - code: ORPCErrorCode, - f: (error: AnyORPCError) => AnyEffect, - ) => self.pipe( - Effect.catchIf((error): error is AnyORPCError => error instanceof ORPCError && error.code === code, f), - ), + (self: AnyEffect, code: ORPCErrorCode, f: (error: AnyORPCError) => AnyEffect) => + self.pipe( + Effect.catchIf( + (error): error is AnyORPCError => error instanceof ORPCError && error.code === code, + f, + ), + ), ) /** @@ -165,13 +181,12 @@ export const catchORPCErrorCodes: { ): CatchORPCErrorCodesEffect } = /* @__PURE__ */ Function.dual( 2, - ( - self: AnyEffect, - cases: Record AnyEffect) | undefined>, - ) => self.pipe( - Effect.catchIf( - (error): error is AnyORPCError => error instanceof ORPCError && typeof getOwn(cases, error.code) === 'function', - error => getOwn(cases, error.code)!(error), + (self: AnyEffect, cases: Record AnyEffect) | undefined>) => + self.pipe( + Effect.catchIf( + (error): error is AnyORPCError => + error instanceof ORPCError && typeof getOwn(cases, error.code) === 'function', + (error) => getOwn(cases, error.code)!(error), + ), ), - ), ) diff --git a/packages/effect/src/extensions/effect.test-d.ts b/packages/effect/src/extensions/effect.test-d.ts index c26b40ad3b..0feb79551c 100644 --- a/packages/effect/src/extensions/effect.test-d.ts +++ b/packages/effect/src/extensions/effect.test-d.ts @@ -1,7 +1,16 @@ import type { Schema } from '@orpc/contract' -import type { Builder, BuilderWithInput, BuilderWithInputOutput, BuilderWithMiddlewares, BuilderWithOutput, DecoratedProcedure, ORPCErrorConstructorMap } from '@orpc/server' +import type { + Builder, + BuilderWithInput, + BuilderWithInputOutput, + BuilderWithMiddlewares, + BuilderWithOutput, + DecoratedProcedure, + ORPCErrorConstructorMap, +} from '@orpc/server' import { z } from 'zod' -import './effect' + +import './effect.ts' import '@orpc/server/extensions/callable' // not sure why, but we need import this to make type work const errorMap = { @@ -11,20 +20,22 @@ const errorMap = { }, } -const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +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< + 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, @@ -37,16 +48,22 @@ describe('adds .effect into Builder', async () => { }) describe('adds .effect into BuilderWithMiddlewares', async () => { - const builder = {} as BuilderWithMiddlewares<{ auth: boolean }, { extra: boolean }, typeof errorMap> + 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< + 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 }, @@ -59,16 +76,23 @@ describe('adds .effect into BuilderWithMiddlewares', async () => { }) describe('adds .effect into BuilderWithInput', async () => { - const builder = {} as BuilderWithInput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof errorMap> + 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< + 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 }, @@ -81,16 +105,23 @@ describe('adds .effect into BuilderWithInput', async () => { }) describe('adds .effect into BuilderWithOutput', async () => { - const builder = {} as BuilderWithOutput<{ auth: boolean }, { extra: boolean }, typeof schema2, typeof errorMap> + 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< + 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 }, @@ -108,16 +139,24 @@ describe('adds .effect into BuilderWithOutput', async () => { }) describe('adds .effect into BuilderWithInputOutput', async () => { - const builder = {} as BuilderWithInputOutput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema2, typeof errorMap> + 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< + 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 }, diff --git a/packages/effect/src/extensions/effect.test.ts b/packages/effect/src/extensions/effect.test.ts index 503268ea52..731081643b 100644 --- a/packages/effect/src/extensions/effect.test.ts +++ b/packages/effect/src/extensions/effect.test.ts @@ -1,9 +1,11 @@ import { oc } from '@orpc/contract' import { call, DecoratedProcedure, implement, ImplementedProcedure, os } from '@orpc/server' import { z } from 'zod' -import * as Handler from '../handler' + +import * as Handler from '../handler.ts' import '@orpc/server/extensions/callable' // not sure why, but we need import this to make type work -import './effect' + +import './effect.ts' const handlerGen = vi.spyOn(Handler, 'handlerGen') @@ -16,10 +18,7 @@ it('adds .effect into Builder', async () => { const handler = vi.fn(function* ({ input, context }) { return { output: true, auth: context.auth, input } }) - const procedure = os - .$context<{ auth: boolean }>() - .input(InputSchema) - .effect(handler) + const procedure = os.$context<{ auth: boolean }>().input(InputSchema).effect(handler) expect(handlerGen).toHaveBeenCalledTimes(1) expect(handlerGen).toHaveBeenNthCalledWith(1, handler) @@ -28,7 +27,11 @@ it('adds .effect into Builder', async () => { 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' }) + await expect(call(procedure, 'input', { context: { auth: false } })).resolves.toEqual({ + output: true, + auth: false, + input: 'input', + }) }) it('adds .effect into ProcedureImplementer', async () => { @@ -41,10 +44,7 @@ it('adds .effect into ProcedureImplementer', async () => { ping: oc.input(InputSchema), }) - const procedure = os - .$context<{ auth: boolean }>() - .ping - .effect(handler) + const procedure = os.$context<{ auth: boolean }>().ping.effect(handler) expect(handlerGen).toHaveBeenCalledTimes(1) expect(handlerGen).toHaveBeenNthCalledWith(1, handler) @@ -53,5 +53,9 @@ it('adds .effect into ProcedureImplementer', async () => { 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' }) + 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 index cb91e88b6b..461150a98c 100644 --- a/packages/effect/src/extensions/effect.ts +++ b/packages/effect/src/extensions/effect.ts @@ -1,20 +1,28 @@ -import type { AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, InitialInputSchema, Schema } from '@orpc/contract' -import type { Context, DecoratedProcedure, ImplementedProcedure, MergedContext, ORPCErrorConstructorMap } from '@orpc/server' -import type { Effect } from 'effect' -import type { InferEffectServices } from '../context' -import type { HandlerGen } from '../handler' +import type { + AnySchema, + ErrorMap, + InferSchemaInput, + InferSchemaOutput, + InitialInputSchema, + Schema, +} from '@orpc/contract' +import type { + Context, + DecoratedProcedure, + ImplementedProcedure, + MergedContext, + ORPCErrorConstructorMap, +} from '@orpc/server' import { Builder, ProcedureImplementer } from '@orpc/server' -import { handlerGen } from '../handler' +import type { Effect } from 'effect' + +import type { InferEffectServices } from '../context.ts' +import type { HandlerGen } from '../handler.ts' +import { handlerGen } from '../handler.ts' declare module '@orpc/server' { - interface Builder< - TInitialContext extends Context, - TErrorMap extends ErrorMap, - > { - effect< - TYield extends Effect.Effect>, - TReturn, - >( + interface Builder { + effect>, TReturn>( handler: HandlerGen< TInitialContext, InferSchemaOutput, @@ -22,13 +30,7 @@ declare module '@orpc/server' { ORPCErrorConstructorMap, TYield >, - ): DecoratedProcedure< - TInitialContext, - object, - InitialInputSchema, - Schema, - TErrorMap - > + ): DecoratedProcedure, TErrorMap> } interface BuilderWithMiddlewares< @@ -37,7 +39,11 @@ declare module '@orpc/server' { TErrorMap extends ErrorMap, > { effect< - TYield extends Effect.Effect>>, + TYield extends Effect.Effect< + any, + any, + InferEffectServices> + >, TReturn, >( handler: HandlerGen< @@ -63,7 +69,11 @@ declare module '@orpc/server' { TErrorMap extends ErrorMap, > { effect< - TYield extends Effect.Effect>>, + TYield extends Effect.Effect< + any, + any, + InferEffectServices> + >, TReturn, >( handler: HandlerGen< @@ -89,7 +99,11 @@ declare module '@orpc/server' { TErrorMap extends ErrorMap, > { effect< - TYield extends Effect.Effect>>, + TYield extends Effect.Effect< + any, + any, + InferEffectServices> + >, TReturn extends InferSchemaInput, >( handler: HandlerGen< @@ -116,7 +130,11 @@ declare module '@orpc/server' { TErrorMap extends ErrorMap, > { effect< - TYield extends Effect.Effect>>, + TYield extends Effect.Effect< + any, + any, + InferEffectServices> + >, TReturn extends InferSchemaInput, >( handler: HandlerGen< @@ -126,13 +144,7 @@ declare module '@orpc/server' { ORPCErrorConstructorMap, TYield >, - ): DecoratedProcedure< - TInitialContext, - TInjectedContext, - TInputSchema, - TOutputSchema, - TErrorMap - > + ): DecoratedProcedure } interface ProcedureImplementer< @@ -148,7 +160,11 @@ declare module '@orpc/server' { InferSchemaOutput, InferSchemaInput, ORPCErrorConstructorMap, - Effect.Effect>> + Effect.Effect< + any, + any, + InferEffectServices> + > >, ): ImplementedProcedure< TInitialContext, diff --git a/packages/effect/src/extensions/schema.test-d.ts b/packages/effect/src/extensions/schema.test-d.ts index f200bdda67..431871693a 100644 --- a/packages/effect/src/extensions/schema.test-d.ts +++ b/packages/effect/src/extensions/schema.test-d.ts @@ -1,9 +1,25 @@ -import type { ContractBuilder, MergedErrorMap, MergedSchema, ProcedureContractBuilderWithInput, ProcedureContractBuilderWithInputOutput, ProcedureContractBuilderWithOutput } from '@orpc/contract' -import type { Builder, BuilderWithInput, BuilderWithInputOutput, BuilderWithMiddlewares, BuilderWithOutput, DecoratedProcedure, Schema } from '@orpc/server' +import type { + ContractBuilder, + MergedErrorMap, + MergedSchema, + ProcedureContractBuilderWithInput, + ProcedureContractBuilderWithInputOutput, + ProcedureContractBuilderWithOutput, +} from '@orpc/contract' import { error } from '@orpc/contract' +import type { + Builder, + BuilderWithInput, + BuilderWithInputOutput, + BuilderWithMiddlewares, + BuilderWithOutput, + DecoratedProcedure, + Schema, +} from '@orpc/server' import { Schema as EffectSchema } from 'effect' import { z } from 'zod' -import './schema' + +import './schema.ts' const errorMap = { BASE: { @@ -12,8 +28,8 @@ const errorMap = { }, } -const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +const schema1 = z.object({ schema1: z.number().transform((n) => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform((n) => `${n}`) }) const NumberFromString = EffectSchema.NumberFromString @@ -27,8 +43,8 @@ const effectErrorMap = { } type ExpectedEffectErrorMap = { - STANDARD: { message: string, data: typeof schema1 } - EFFECT: { message: string, data: Schema } + STANDARD: { message: string; data: typeof schema1 } + EFFECT: { message: string; data: Schema } NO_DATA: { message: string } FACTORY: typeof FactoryError } @@ -37,23 +53,15 @@ it('adds .input .output .errors into C const builder = {} as ContractBuilder expectTypeOf(builder.errors(effectErrorMap)).toEqualTypeOf< - ContractBuilder< - MergedErrorMap - > + ContractBuilder> >() expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< - ProcedureContractBuilderWithInput< - Schema, - typeof errorMap - > + ProcedureContractBuilderWithInput, typeof errorMap> >() expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< - ProcedureContractBuilderWithOutput< - Schema, - typeof errorMap - > + ProcedureContractBuilderWithOutput, typeof errorMap> >() }) @@ -75,11 +83,7 @@ describe('adds .input .output .errors >() expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< - ProcedureContractBuilderWithInputOutput< - typeof schema1, - Schema, - typeof errorMap - > + ProcedureContractBuilderWithInputOutput, typeof errorMap> >() }) @@ -94,11 +98,7 @@ describe('adds .input .output .errors >() expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< - ProcedureContractBuilderWithInputOutput< - Schema, - typeof schema2, - typeof errorMap - > + ProcedureContractBuilderWithInputOutput, typeof schema2, typeof errorMap> >() expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< @@ -110,7 +110,11 @@ describe('adds .input .output .errors }) describe('adds .input .output .errors into ProcedureContractBuilderWithInputOutput', async () => { - const builder = {} as ProcedureContractBuilderWithInputOutput + const builder = {} as ProcedureContractBuilderWithInputOutput< + typeof schema1, + typeof schema2, + typeof errorMap + > expectTypeOf(builder.errors(effectErrorMap)).toEqualTypeOf< ProcedureContractBuilderWithInputOutput< @@ -141,33 +145,24 @@ it('adds .input .output .errors into B const builder = {} as Builder<{ auth: boolean }, typeof errorMap> expectTypeOf(builder.errors(effectErrorMap)).toEqualTypeOf< - Builder< - { auth: boolean }, - MergedErrorMap - > + Builder<{ auth: boolean }, MergedErrorMap> >() expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< - BuilderWithInput< - { auth: boolean }, - object, - Schema, - typeof errorMap - > + BuilderWithInput<{ auth: boolean }, object, Schema, typeof errorMap> >() expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< - BuilderWithOutput< - { auth: boolean }, - object, - Schema, - typeof errorMap - > + BuilderWithOutput<{ auth: boolean }, object, Schema, typeof errorMap> >() }) describe('adds .input .output .errors into BuilderWithMiddlewares', async () => { - const builder = {} as BuilderWithMiddlewares<{ auth: boolean }, { extra: boolean }, typeof errorMap> + const builder = {} as BuilderWithMiddlewares< + { auth: boolean }, + { extra: boolean }, + typeof errorMap + > expectTypeOf(builder.errors(effectErrorMap)).toEqualTypeOf< BuilderWithMiddlewares< @@ -178,12 +173,7 @@ describe('adds .input .output .errors >() expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< - BuilderWithInput< - { auth: boolean }, - { extra: boolean }, - Schema, - typeof errorMap - > + BuilderWithInput<{ auth: boolean }, { extra: boolean }, Schema, typeof errorMap> >() expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< @@ -197,7 +187,12 @@ describe('adds .input .output .errors }) describe('adds .input .output .errors into BuilderWithInput', async () => { - const builder = {} as BuilderWithInput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof errorMap> + const builder = {} as BuilderWithInput< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof errorMap + > expectTypeOf(builder.errors(effectErrorMap)).toEqualTypeOf< BuilderWithInput< @@ -229,7 +224,12 @@ describe('adds .input .output .errors }) describe('adds .input .output .errors into BuilderWithOutput', async () => { - const builder = {} as BuilderWithOutput<{ auth: boolean }, { extra: boolean }, typeof schema2, typeof errorMap> + const builder = {} as BuilderWithOutput< + { auth: boolean }, + { extra: boolean }, + typeof schema2, + typeof errorMap + > expectTypeOf(builder.errors(effectErrorMap)).toEqualTypeOf< BuilderWithOutput< @@ -261,7 +261,13 @@ describe('adds .input .output .errors }) describe('adds .input .output .errors into BuilderWithInputOutput', async () => { - const builder = {} as BuilderWithInputOutput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema2, typeof errorMap> + const builder = {} as BuilderWithInputOutput< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap + > expectTypeOf(builder.errors(effectErrorMap)).toEqualTypeOf< BuilderWithInputOutput< @@ -295,7 +301,13 @@ describe('adds .input .output .errors }) it('adds .errors into DecoratedProcedure', async () => { - const procedure = {} as DecoratedProcedure<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema2, typeof errorMap> + const procedure = {} as DecoratedProcedure< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap + > expectTypeOf(procedure.errors(effectErrorMap)).toEqualTypeOf< DecoratedProcedure< @@ -315,10 +327,7 @@ describe('.errors', () => { const standardErrorMap = { STANDARD: { message: 'standard', data: schema1 } } expectTypeOf(builder.errors(standardErrorMap)).toEqualTypeOf< - Builder< - { auth: boolean }, - MergedErrorMap - > + Builder<{ auth: boolean }, MergedErrorMap> >() }) diff --git a/packages/effect/src/extensions/schema.test.ts b/packages/effect/src/extensions/schema.test.ts index 459ba98c7a..789f47096d 100644 --- a/packages/effect/src/extensions/schema.test.ts +++ b/packages/effect/src/extensions/schema.test.ts @@ -3,8 +3,9 @@ import { oc, setHiddenMetaPlugins } from '@orpc/contract' import { call, os } from '@orpc/server' import { Schema as EffectSchema } from 'effect' import { z } from 'zod' -import * as SchemaModule from '../schema' -import './schema' + +import * as SchemaModule from '../schema.ts' +import './schema.ts' const toStandardSchemaSpy = vi.spyOn(SchemaModule, 'toStandardSchema') @@ -12,8 +13,8 @@ beforeEach(() => { vi.clearAllMocks() }) -const standardSchema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const standardSchema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +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 }) @@ -28,8 +29,14 @@ it('accepts both standard schema and effect schema in ContractBuilder', () => { 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]) + 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', () => { @@ -43,8 +50,14 @@ it('accepts both standard schema and effect schema in Builder', () => { 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]) + expect(builder['~orpc'].inputSchemas).toEqual([ + standardSchema1, + toStandardSchemaSpy.mock.results[0]?.value, + ]) + expect(builder['~orpc'].outputSchemas).toEqual([ + toStandardSchemaSpy.mock.results[1]?.value, + standardSchema2, + ]) }) describe('.errors', () => { @@ -91,9 +104,7 @@ describe('.errors', () => { }) it('accepts both standard schema and effect schema in DecoratedProcedure', () => { - const procedure = os - .handler(() => 'output') - .errors(errors) + const procedure = os.handler(() => 'output').errors(errors) expect(toStandardSchemaSpy).toHaveBeenCalledTimes(1) expect(toStandardSchemaSpy).toHaveBeenCalledWith(effectSchema1) @@ -134,7 +145,7 @@ describe('.errors', () => { throw errors.EFFECT({ data: { value: '123' } }) }) - const error = await call(procedure, undefined).catch(e => e) + const error = await call(procedure, undefined).catch((e) => e) expect(isDefinedError(error)).toBe(true) expect(error.code).toBe('EFFECT') diff --git a/packages/effect/src/extensions/schema.ts b/packages/effect/src/extensions/schema.ts index 445e2903e6..181fc2b980 100644 --- a/packages/effect/src/extensions/schema.ts +++ b/packages/effect/src/extensions/schema.ts @@ -1,10 +1,18 @@ import type { ORPCErrorCode } from '@orpc/client' -import type { AnySchema, ErrorMap, ErrorMapItem, MergedErrorMap, MergedSchema, Schema } from '@orpc/contract' -import type { Context } from '@orpc/server' +import type { + AnySchema, + ErrorMap, + ErrorMapItem, + MergedErrorMap, + MergedSchema, + Schema, +} from '@orpc/contract' import { ContractBuilder } from '@orpc/contract' +import type { Context } from '@orpc/server' import { Builder, DecoratedProcedure } from '@orpc/server' import { Schema as EffectSchema } from 'effect' -import { toStandardSchema } from '../schema' + +import { toStandardSchema } from '../schema.ts' /** * Like `ErrorMapItem`, but `data` can also be an Effect Schema. @@ -40,14 +48,14 @@ export type ToStandardErrorMap = T extends ErrorMap ? T[K] : { [P in keyof T[K]]: P extends 'data' - ? T[K][P] extends infer S extends EffectSchema.ConstraintDecoder ? Schema : T[K][P] + ? T[K][P] extends infer S extends EffectSchema.ConstraintDecoder + ? Schema + : T[K][P] : T[K][P] } } -function isEffectConstraintDecoder( - schema: unknown, -): schema is EffectSchema.ConstraintDecoder { +function isEffectConstraintDecoder(schema: unknown): schema is EffectSchema.ConstraintDecoder { return EffectSchema.isSchema(schema) } @@ -64,9 +72,7 @@ function toStandardErrorMap(errors: EffectErrorMap): ErrorMap { } declare module '@orpc/contract' { - interface ContractBuilder< - TErrorMap extends ErrorMap, - > { + interface ContractBuilder { errors( errors: T, ): ContractBuilder>> @@ -86,15 +92,25 @@ declare module '@orpc/contract' { > { errors( errors: T, - ): ProcedureContractBuilderWithInput>> + ): ProcedureContractBuilderWithInput< + TInputSchema, + MergedErrorMap> + > input>( schema: S, - ): ProcedureContractBuilderWithInput, TInputSchema>, TErrorMap> + ): ProcedureContractBuilderWithInput< + MergedSchema, TInputSchema>, + TErrorMap + > output>( schema: S, - ): ProcedureContractBuilderWithInputOutput, TErrorMap> + ): ProcedureContractBuilderWithInputOutput< + TInputSchema, + Schema, + TErrorMap + > } interface ProcedureContractBuilderWithOutput< @@ -103,15 +119,25 @@ declare module '@orpc/contract' { > { errors( errors: T, - ): ProcedureContractBuilderWithOutput>> + ): ProcedureContractBuilderWithOutput< + TOutputSchema, + MergedErrorMap> + > input>( schema: S, - ): ProcedureContractBuilderWithInputOutput, TOutputSchema, TErrorMap> + ): ProcedureContractBuilderWithInputOutput< + Schema, + TOutputSchema, + TErrorMap + > output>( schema: S, - ): ProcedureContractBuilderWithOutput, TOutputSchema>, TErrorMap> + ): ProcedureContractBuilderWithOutput< + MergedSchema, TOutputSchema>, + TErrorMap + > } interface ProcedureContractBuilderWithInputOutput< @@ -121,15 +147,27 @@ declare module '@orpc/contract' { > { errors( errors: T, - ): ProcedureContractBuilderWithInputOutput>> + ): ProcedureContractBuilderWithInputOutput< + TInputSchema, + TOutputSchema, + MergedErrorMap> + > input>( schema: S, - ): ProcedureContractBuilderWithInputOutput, TInputSchema>, TOutputSchema, TErrorMap> + ): ProcedureContractBuilderWithInputOutput< + MergedSchema, TInputSchema>, + TOutputSchema, + TErrorMap + > output>( schema: S, - ): ProcedureContractBuilderWithInputOutput, TOutputSchema>, TErrorMap> + ): ProcedureContractBuilderWithInputOutput< + TInputSchema, + MergedSchema, TOutputSchema>, + TErrorMap + > } } @@ -139,20 +177,25 @@ ContractBuilder.prototype.errors = function errors(errors: EffectErrorMap) { } const OriginalContractBuilderInput = ContractBuilder.prototype.input -ContractBuilder.prototype.input = function input(schema: AnySchema | EffectSchema.ConstraintDecoder) { - return OriginalContractBuilderInput.bind(this)(isEffectConstraintDecoder(schema) ? toStandardSchema(schema) : schema) +ContractBuilder.prototype.input = function input( + schema: AnySchema | EffectSchema.ConstraintDecoder, +) { + return OriginalContractBuilderInput.bind(this)( + isEffectConstraintDecoder(schema) ? toStandardSchema(schema) : schema, + ) } const OriginalContractBuilderOutput = ContractBuilder.prototype.output -ContractBuilder.prototype.output = function output(schema: AnySchema | EffectSchema.ConstraintDecoder) { - return OriginalContractBuilderOutput.bind(this)(isEffectConstraintDecoder(schema) ? toStandardSchema(schema) : schema) +ContractBuilder.prototype.output = function output( + schema: AnySchema | EffectSchema.ConstraintDecoder, +) { + return OriginalContractBuilderOutput.bind(this)( + isEffectConstraintDecoder(schema) ? toStandardSchema(schema) : schema, + ) } declare module '@orpc/server' { - interface Builder< - TInitialContext extends Context, - TErrorMap extends ErrorMap, - > { + interface Builder { errors( errors: T, ): Builder>> @@ -173,15 +216,29 @@ declare module '@orpc/server' { > { errors( errors: T, - ): BuilderWithMiddlewares>> + ): BuilderWithMiddlewares< + TInitialContext, + TInjectedContext, + MergedErrorMap> + > input>( schema: S, - ): BuilderWithInput, TErrorMap> + ): BuilderWithInput< + TInitialContext, + TInjectedContext, + Schema, + TErrorMap + > output>( schema: S, - ): BuilderWithOutput, TErrorMap> + ): BuilderWithOutput< + TInitialContext, + TInjectedContext, + Schema, + TErrorMap + > } interface BuilderWithInput< @@ -192,15 +249,31 @@ declare module '@orpc/server' { > { errors( errors: T, - ): BuilderWithInput>> + ): BuilderWithInput< + TInitialContext, + TInjectedContext, + TInputSchema, + MergedErrorMap> + > input>( schema: S, - ): BuilderWithInput, TInputSchema>, TErrorMap> + ): BuilderWithInput< + TInitialContext, + TInjectedContext, + MergedSchema, TInputSchema>, + TErrorMap + > output>( schema: S, - ): BuilderWithInputOutput, TErrorMap> + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + TInputSchema, + Schema, + TErrorMap + > } interface BuilderWithOutput< @@ -211,15 +284,31 @@ declare module '@orpc/server' { > { errors( errors: T, - ): BuilderWithOutput>> + ): BuilderWithOutput< + TInitialContext, + TInjectedContext, + TOutputSchema, + MergedErrorMap> + > input>( schema: S, - ): BuilderWithInputOutput, TOutputSchema, TErrorMap> + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + Schema, + TOutputSchema, + TErrorMap + > output>( schema: S, - ): BuilderWithOutput, TOutputSchema>, TErrorMap> + ): BuilderWithOutput< + TInitialContext, + TInjectedContext, + MergedSchema, TOutputSchema>, + TErrorMap + > } interface BuilderWithInputOutput< @@ -231,16 +320,33 @@ declare module '@orpc/server' { > { errors( errors: T, - ): BuilderWithInputOutput>> + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + TInputSchema, + TOutputSchema, + MergedErrorMap> + > input>( schema: S, - ): BuilderWithInputOutput, TInputSchema>, TOutputSchema, TErrorMap> + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + MergedSchema, TInputSchema>, + TOutputSchema, + TErrorMap + > output>( schema: S, - ): BuilderWithInputOutput, TOutputSchema>, TErrorMap> - + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + TInputSchema, + MergedSchema, TOutputSchema>, + TErrorMap + > } interface DecoratedProcedure< @@ -252,7 +358,13 @@ declare module '@orpc/server' { > { errors( errors: T, - ): DecoratedProcedure>> + ): DecoratedProcedure< + TInitialContext, + TInjectedContext, + TInputSchema, + TOutputSchema, + MergedErrorMap> + > } } @@ -263,12 +375,18 @@ Builder.prototype.errors = function errors(errors: EffectErrorMap) { const OriginalBuilderInput = Builder.prototype.input Builder.prototype.input = function input(schema: AnySchema | EffectSchema.ConstraintDecoder) { - return OriginalBuilderInput.bind(this)(isEffectConstraintDecoder(schema) ? toStandardSchema(schema) : schema) + return OriginalBuilderInput.bind(this)( + isEffectConstraintDecoder(schema) ? toStandardSchema(schema) : schema, + ) } const OriginalBuilderOutput = Builder.prototype.output -Builder.prototype.output = function output(schema: AnySchema | EffectSchema.ConstraintDecoder) { - return OriginalBuilderOutput.bind(this)(isEffectConstraintDecoder(schema) ? toStandardSchema(schema) : schema) +Builder.prototype.output = function output( + schema: AnySchema | EffectSchema.ConstraintDecoder, +) { + return OriginalBuilderOutput.bind(this)( + isEffectConstraintDecoder(schema) ? toStandardSchema(schema) : schema, + ) } const OriginalDecoratedProcedureErrors = DecoratedProcedure.prototype.errors diff --git a/packages/effect/src/handler.test-d.ts b/packages/effect/src/handler.test-d.ts index b33efd24f9..c6be99db4b 100644 --- a/packages/effect/src/handler.test-d.ts +++ b/packages/effect/src/handler.test-d.ts @@ -1,10 +1,16 @@ import type { InitialInputSchema, Schema } from '@orpc/contract' -import type { DecoratedProcedure, DefaultInitialContext, ORPCErrorConstructorMap, ProcedureHandler } from '@orpc/server' -import type { WithEffectContext } from './context' +import type { + DecoratedProcedure, + DefaultInitialContext, + ORPCErrorConstructorMap, + ProcedureHandler, +} from '@orpc/server' import { ORPCError, os } from '@orpc/server' import { Context, Effect } from 'effect' import { z } from 'zod' -import { handlerGen } from './handler' + +import type { WithEffectContext } from './context.ts' +import { handlerGen } from './handler.ts' class Service1 extends Context.Service< Service1, @@ -27,20 +33,21 @@ const errorMap = { }, } -const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +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* () { + 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 })) return true - })) + }), + ) expectTypeOf(procedure).toEqualTypeOf< DecoratedProcedure< @@ -60,17 +67,19 @@ describe('handlerGen', () => { .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 } & object>() - expectTypeOf(errors).toEqualTypeOf>() + .handler( + handlerGen(function* ({ input, context, errors }) { + expectTypeOf(input).toEqualTypeOf<{ schema1: string }>() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { extra: boolean } & object>() + 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 })) + // 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 })) - return { schema2: 123 } - })) + return { schema2: 123 } + }), + ) expectTypeOf(procedure).toEqualTypeOf< DecoratedProcedure< @@ -88,45 +97,58 @@ describe('handlerGen', () => { .output(schema2) .errors(errorMap) .use(({ next }) => next({ context: { extra: true } })) - // @ts-expect-error - invalid output - .handler(handlerGen(function* () { - return 'invalid' - })) + .handler( + // @ts-expect-error - invalid output + handlerGen(function* () { + return 'invalid' + }), + ) }) it('can strict dependant-effect-service with WithEffectContext', async () => { - void os - .$context>() - .handler(handlerGen(function* () { + void os.$context>().handler( + handlerGen(function* () { yield* Service1 - })) + }), + ) - void os - .$context>() + void os.$context>().handler( // @ts-expect-error - Random2 is not provided - .handler(handlerGen(function* () { + handlerGen(function* () { yield* Service2 - })) + }), + ) }) it('accepts explicit type arguments in ProcedureHandler order with TYield last', () => { type Errors = ORPCErrorConstructorMap - const handler = handlerGen, { id: string }, number, Errors, Effect.Effect>( - function* ({ errors }, input) { - expectTypeOf(input).toEqualTypeOf<{ id: string }>() - expectTypeOf(errors).toEqualTypeOf() - yield* Service1 - return 1 - }, - ) - expectTypeOf(handler).toEqualTypeOf, { id: string }, number, Errors>>() + const handler = handlerGen< + WithEffectContext, + { id: string }, + number, + Errors, + Effect.Effect + >(function* ({ errors }, input) { + expectTypeOf(input).toEqualTypeOf<{ id: string }>() + expectTypeOf(errors).toEqualTypeOf() + yield* Service1 + return 1 + }) + expectTypeOf(handler).toEqualTypeOf< + ProcedureHandler, { id: string }, number, Errors> + >() const defaults = handlerGen>(function* () { yield* Service1 }) expectTypeOf(defaults).toEqualTypeOf< - ProcedureHandler, unknown, unknown, ORPCErrorConstructorMap>> + ProcedureHandler< + WithEffectContext, + unknown, + unknown, + ORPCErrorConstructorMap> + > >() }) }) diff --git a/packages/effect/src/handler.test.ts b/packages/effect/src/handler.test.ts index c3d4648ee7..3d10127b51 100644 --- a/packages/effect/src/handler.test.ts +++ b/packages/effect/src/handler.test.ts @@ -1,9 +1,10 @@ -import type { EffectWrapOptions, WithEffectContext } from './context' import { call, ORPCError, os, type } from '@orpc/server' import { Cause, Context, Effect, Option, Tracer } from 'effect' import { z } from 'zod' -import { handlerGen } from './handler' -import * as EffectModule from './runtime' + +import type { EffectWrapOptions, WithEffectContext } from './context.ts' +import { handlerGen } from './handler.ts' +import * as EffectModule from './runtime.ts' const runPromiseSpy = vi.spyOn(EffectModule, 'runPromise') @@ -28,17 +29,25 @@ class Service2 extends Context.Service< describe('handlerGen', () => { it('works with native Effect syntax, and throws failed ORPCError as-is', async () => { await expect( - call(os.handler(handlerGen(function* () { - return 'output' - }))), + call( + os.handler( + handlerGen(function* () { + return 'output' + }), + ), + ), ).resolves.toEqual('output') const error = new ORPCError('__TEST__') await expect( - call(os.handler(handlerGen(function* () { - yield* Effect.fail(error) - }))), + call( + os.handler( + handlerGen(function* () { + yield* Effect.fail(error) + }), + ), + ), ).rejects.toBe(error) expect(error.defined).toBe(false) }) @@ -48,61 +57,81 @@ describe('handlerGen', () => { await expect( call( - os.handler(handlerGen(function* () { - return 'output' - })), + os.handler( + handlerGen(function* () { + return 'output' + }), + ), undefined, { signal: controller.signal }, ), ).resolves.toEqual('output') expect(runPromiseSpy).toHaveBeenCalledTimes(1) - expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { context: undefined, signal: controller.signal }) + expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { + context: undefined, + 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' - })) + 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'] }, - ), + 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') + 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 handler options', async () => { @@ -112,18 +141,22 @@ describe('handlerGen', () => { .$context>() .input(z.object({ id: z.string().transform(Number) })) .errors({ BASE: {} }) - .handler(handlerGen(function* () { - return 'output' - })) + .handler( + handlerGen(function* () { + return 'output' + }), + ) const signal = AbortSignal.timeout(100) const context = { 'effect/context': Context.empty(), 'effect/wrap': wrap } - await expect(call( - procedure, - { id: '1' }, - { path: ['wrapped', 'procedure'], context, signal, lastEventId: 'id' }, - )).resolves.toEqual('output') + await expect( + call( + procedure, + { id: '1' }, + { path: ['wrapped', 'procedure'], context, signal, lastEventId: 'id' }, + ), + ).resolves.toEqual('output') expect(wrap).toHaveBeenCalledExactlyOnceWith(expect.anything(), { kind: 'handler', @@ -141,41 +174,50 @@ describe('handlerGen', () => { const domainError = new Error('__DOMAIN__') const error = new ORPCError('BAD_REQUEST', { cause: domainError }) - const procedure = os - .$context>() - .handler(handlerGen(function* () { + const procedure = os.$context>().handler( + handlerGen(function* () { return yield* Effect.fail(domainError) - })) + }), + ) - await expect(call(procedure, undefined, { - context: { - 'effect/context': Context.empty(), - 'effect/wrap': effect => effect.pipe( - Effect.mapError(cause => cause === domainError ? error : cause), - ), - }, - })).rejects.toBe(error) + await expect( + call(procedure, undefined, { + context: { + 'effect/context': Context.empty(), + 'effect/wrap': (effect) => + effect.pipe(Effect.mapError((cause) => (cause === domainError ? error : cause))), + }, + }), + ).rejects.toBe(error) expect(error.defined).toBe(false) }) it('can convert defects to ORPCError via wrap with catchCause', async () => { const defect = new Error('__DEFECT__') - const procedure = os - .$context>() - .handler(handlerGen(function* () { + const procedure = os.$context>().handler( + handlerGen(function* () { throw defect - })) + }), + ) - await expect(call(procedure, undefined, { - context: { - 'effect/context': Context.empty(), - 'effect/wrap': effect => effect.pipe( - Effect.catchCause(cause => Effect.fail(new ORPCError('INTERNAL_SERVER_ERROR', { cause: Cause.squash(cause) }))), - ), - }, - })).rejects.toSatisfy( - error => error instanceof ORPCError && error.code === 'INTERNAL_SERVER_ERROR' && error.cause === defect, + await expect( + call(procedure, undefined, { + context: { + 'effect/context': Context.empty(), + 'effect/wrap': (effect) => + effect.pipe( + Effect.catchCause((cause) => + Effect.fail(new ORPCError('INTERNAL_SERVER_ERROR', { cause: Cause.squash(cause) })), + ), + ), + }, + }), + ).rejects.toSatisfy( + (error) => + error instanceof ORPCError && + error.code === 'INTERNAL_SERVER_ERROR' && + error.cause === defect, ) }) @@ -191,43 +233,49 @@ describe('handlerGen', () => { let serviceInWrap: Option.Option | undefined - const procedure = os - .$context>() - .handler(handlerGen(function* () { + const procedure = os.$context>().handler( + handlerGen(function* () { return yield* Effect.succeed('output').pipe(Effect.withSpan('handler')) - })) + }), + ) - await expect(call(procedure, undefined, { - context: { - 'effect/context': Context.empty().pipe( - Context.add(Service1, { id: 'Service1' }), - Context.add(Tracer.Tracer, tracer), - ), - 'effect/wrap': effect => Effect.gen(function* () { - serviceInWrap = yield* Effect.serviceOption(Service1) - return yield* effect - }).pipe(Effect.withSpan('wrap')), - }, - })).resolves.toEqual('output') + await expect( + call(procedure, undefined, { + context: { + 'effect/context': Context.empty().pipe( + Context.add(Service1, { id: 'Service1' }), + Context.add(Tracer.Tracer, tracer), + ), + 'effect/wrap': (effect) => + Effect.gen(function* () { + serviceInWrap = yield* Effect.serviceOption(Service1) + return yield* effect + }).pipe(Effect.withSpan('wrap')), + }, + }), + ).resolves.toEqual('output') expect(serviceInWrap).toEqual(Option.some({ id: 'Service1' })) - expect(spans.map(span => span.name)).toEqual(['wrap', 'handler']) + expect(spans.map((span) => span.name)).toEqual(['wrap', 'handler']) expect(spans[1]!.parent).toEqual(Option.some(spans[0])) }) it('lets services provided by wrap take precedence over effect/context', async () => { - const procedure = os - .$context>() - .handler(handlerGen(function* () { + const procedure = os.$context>().handler( + handlerGen(function* () { const service1 = yield* Service1 return `output:${service1.id}` - })) + }), + ) - await expect(call(procedure, undefined, { - context: { - 'effect/context': Context.empty().pipe(Context.add(Service1, { id: 'Service1' })), - 'effect/wrap': effect => effect.pipe(Effect.provideService(Service1, { id: 'fromWrap' as any })), - }, - })).resolves.toEqual('output:fromWrap') + await expect( + call(procedure, undefined, { + context: { + 'effect/context': Context.empty().pipe(Context.add(Service1, { id: 'Service1' })), + 'effect/wrap': (effect) => + effect.pipe(Effect.provideService(Service1, { id: 'fromWrap' as any })), + }, + }), + ).resolves.toEqual('output:fromWrap') }) }) diff --git a/packages/effect/src/handler.ts b/packages/effect/src/handler.ts index c9e101f3a9..f6044bacbb 100644 --- a/packages/effect/src/handler.ts +++ b/packages/effect/src/handler.ts @@ -1,7 +1,13 @@ -import type { Context, ORPCErrorConstructorMap, ProcedureHandler, ProcedureHandlerOptions } from '@orpc/server' -import type { InferEffectServices, WithEffectContext } from './context' +import type { + Context, + ORPCErrorConstructorMap, + ProcedureHandler, + ProcedureHandlerOptions, +} from '@orpc/server' import { Effect, Context as EffectContext } from 'effect' -import { runPromise } from './runtime' + +import type { InferEffectServices, WithEffectContext } from './context.ts' +import { runPromise } from './runtime.ts' export interface HandlerGen< TCurrentContext extends Context, @@ -13,11 +19,7 @@ export interface HandlerGen< ( opts: ProcedureHandlerOptions, input: TInput, - ): Generator< - TYield, - TReturn, - never - > + ): Generator } export type AnyHandlerGen = HandlerGen @@ -33,8 +35,14 @@ export function handlerGen< TCurrentContext extends Context, TInput = unknown, TReturn = unknown, - TErrorConstructorMap extends ORPCErrorConstructorMap = ORPCErrorConstructorMap>, - TYield extends Effect.Effect> = Effect.Effect>, + TErrorConstructorMap extends ORPCErrorConstructorMap = ORPCErrorConstructorMap< + Record + >, + TYield extends Effect.Effect> = Effect.Effect< + any, + any, + InferEffectServices + >, >( handler: HandlerGen, ): ProcedureHandler { @@ -43,9 +51,9 @@ export function handlerGen< const wrap = opts.context['effect/wrap'] as WithEffectContext['effect/wrap'] const ef = Effect.gen(() => handler(opts, input)) as Effect.Effect - return runPromise( - typeof wrap === 'function' ? wrap(ef, { ...opts, kind: 'handler' }) : ef, - { context: EffectContext.isContext(effectContext) ? effectContext : undefined, signal: opts.signal }, - ) + return runPromise(typeof wrap === 'function' ? wrap(ef, { ...opts, kind: 'handler' }) : ef, { + context: EffectContext.isContext(effectContext) ? effectContext : undefined, + signal: opts.signal, + }) } } diff --git a/packages/effect/src/index.test.ts b/packages/effect/src/index.test.ts index 181640da9c..5e7bb051fd 100644 --- a/packages/effect/src/index.test.ts +++ b/packages/effect/src/index.test.ts @@ -1,5 +1,5 @@ it('exports', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ handlerGen: expect.any(Function), middlewareGen: expect.any(Function), EffectSchemaToJsonSchemaConverter: expect.any(Function), diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts index 42f4d9fea5..388b1af720 100644 --- a/packages/effect/src/index.ts +++ b/packages/effect/src/index.ts @@ -1,10 +1,8 @@ -export * from './client' -export * from './context' -export * from './converter' -export * from './error' -export * from './handler' -export * from './middleware' +export * from './client.ts' +export * from './context.ts' +export * from './converter.ts' +export * from './error.ts' +export * from './handler.ts' +export * from './middleware.ts' -export type { - JsonSchema, -} from '@orpc/json-schema' +export type { JsonSchema } from '@orpc/json-schema' diff --git a/packages/effect/src/middleware.test-d.ts b/packages/effect/src/middleware.test-d.ts index 79a972e1a5..3df78284a0 100644 --- a/packages/effect/src/middleware.test-d.ts +++ b/packages/effect/src/middleware.test-d.ts @@ -1,9 +1,10 @@ import type { Middleware, ORPCErrorConstructorMap } from '@orpc/server' -import type { WithEffectContext } from './context' import { os } from '@orpc/server' import { Context, Effect } from 'effect' import { z } from 'zod' -import { middlewareGen } from './middleware' + +import type { WithEffectContext } from './context.ts' +import { middlewareGen } from './middleware.ts' class Service1 extends Context.Service< Service1, @@ -31,11 +32,13 @@ describe('middlewareGen', () => { const procedure = os .$context<{ auth: boolean }>() .errors(errorMap) - .use(middlewareGen(function* ({ context, next }) { - expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & object>() + .use( + middlewareGen(function* ({ context, next }) { + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & object>() - return yield* next({ context: { user: 'user' as const } }) - })) + return yield* next({ context: { user: 'user' as const } }) + }), + ) .handler(({ context }) => { expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { user: 'user' }>() return 'output' @@ -45,55 +48,63 @@ describe('middlewareGen', () => { }) it('exposes downstream failures in the error channel of next', () => { - void os.use(middlewareGen(function* ({ next }) { - expectTypeOf(next()).toEqualTypeOf>() - expectTypeOf(next({ context: { user: 'user' as const } })).toEqualTypeOf>() - - const recovered = next().pipe( - Effect.catch((error) => { - expectTypeOf(error).toEqualTypeOf() - return Effect.fail(error) - }), - ) - - return yield* recovered - })) + void os.use( + middlewareGen(function* ({ next }) { + expectTypeOf(next()).toEqualTypeOf< + Effect.Effect<{ context: object; output: unknown }, unknown> + >() + expectTypeOf(next({ context: { user: 'user' as const } })).toEqualTypeOf< + Effect.Effect<{ context: { user: 'user' }; output: unknown }, unknown> + >() + + const recovered = next().pipe( + Effect.catch((error) => { + expectTypeOf(error).toEqualTypeOf() + return Effect.fail(error) + }), + ) + + return yield* recovered + }), + ) }) it('can terminate the chain early with done', () => { - void os.use(middlewareGen(function* ({ next }, input, done) { - if (Math.random() < 0.5) { - return done({ output: 'early' }) - } + void os.use( + middlewareGen(function* ({ next }, input, done) { + if (Math.random() < 0.5) { + return done({ output: 'early' }) + } - return yield* next() - })) + return yield* next() + }), + ) }) it('can strict dependant-effect-service with WithEffectContext', () => { - void os - .$context>() - .use(middlewareGen(function* ({ next }) { + void os.$context>().use( + middlewareGen(function* ({ next }) { yield* Service1 return yield* next() - })) + }), + ) - void os - .$context>() + void os.$context>().use( // @ts-expect-error - Service2 is not provided - .use(middlewareGen(function* ({ next }) { + middlewareGen(function* ({ next }) { yield* Service2 return yield* next() - })) + }), + ) }) it('works with os.middleware for standalone middleware', () => { - const middleware = os - .$context<{ auth: boolean }>() - .middleware(middlewareGen(function* ({ context, next }) { + const middleware = os.$context<{ auth: boolean }>().middleware( + middlewareGen(function* ({ context, next }) { expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & object>() return yield* next({ context: { extra: true } }) - })) + }), + ) const procedure = os .$context<{ auth: boolean }>() @@ -110,14 +121,14 @@ describe('middlewareGen', () => { auth: boolean } - const requireAuth = os - .$context() - .middleware(middlewareGen(function* ({ context, next }) { + const requireAuth = os.$context().middleware( + middlewareGen(function* ({ context, next }) { expectTypeOf(context.auth).toEqualTypeOf() yield* Service1 return yield* next({ context: { user: 'user' as const } }) - })) + }), + ) const procedure = os .$context() @@ -138,14 +149,15 @@ describe('middlewareGen', () => { auth: boolean } - const requireAuth = middlewareGen( - function* ({ context, next }) { - expectTypeOf(context.auth).toEqualTypeOf() - yield* Service1 + const requireAuth = middlewareGen(function* ({ + context, + next, + }) { + expectTypeOf(context.auth).toEqualTypeOf() + yield* Service1 - return yield* next({ context: { user: 'user' as const } }) - }, - ) + return yield* next({ context: { user: 'user' as const } }) + }) const procedure = os .$context() @@ -160,9 +172,7 @@ describe('middlewareGen', () => { void procedure - const decorated = os - .$context() - .middleware(requireAuth) + const decorated = os.$context().middleware(requireAuth) const decoratedProcedure = os .$context() @@ -177,23 +187,36 @@ describe('middlewareGen', () => { it('can infer typed errors through os.middleware', () => { // Unlike `.use`, `os.middleware` types `errors` from the builder's error map // because its middleware parameter references the error map non-generically. - void os - .errors(errorMap) - .middleware(middlewareGen(function* ({ next, errors }) { + void os.errors(errorMap).middleware( + middlewareGen(function* ({ next, errors }) { expectTypeOf(errors).toEqualTypeOf>() return yield* next() - })) + }), + ) }) it('accepts explicit type arguments in Middleware order with TYield last', () => { - const middleware = middlewareGen, { user: 'user' }, { id: string }, number, typeof errorMap, Effect.Effect>( - function* ({ errors, next }, input) { - expectTypeOf(input).toEqualTypeOf<{ id: string }>() - expectTypeOf(errors).toEqualTypeOf>() - yield* Service1 - return yield* next({ context: { user: 'user' as const } }) - }, - ) - expectTypeOf(middleware).toEqualTypeOf, { user: 'user' }, { id: string }, number, typeof errorMap>>() + const middleware = middlewareGen< + WithEffectContext, + { user: 'user' }, + { id: string }, + number, + typeof errorMap, + Effect.Effect + >(function* ({ errors, next }, input) { + expectTypeOf(input).toEqualTypeOf<{ id: string }>() + expectTypeOf(errors).toEqualTypeOf>() + yield* Service1 + return yield* next({ context: { user: 'user' as const } }) + }) + expectTypeOf(middleware).toEqualTypeOf< + Middleware< + WithEffectContext, + { user: 'user' }, + { id: string }, + number, + typeof errorMap + > + >() }) }) diff --git a/packages/effect/src/middleware.test.ts b/packages/effect/src/middleware.test.ts index 3366ac0c60..b674e72140 100644 --- a/packages/effect/src/middleware.test.ts +++ b/packages/effect/src/middleware.test.ts @@ -1,11 +1,12 @@ -import type { EffectWrapOptions, WithEffectContext } from './context' import { call, ORPCError, os } from '@orpc/server' import { sleep } from '@orpc/shared' import { Cause, Context, Effect, Option, References } from 'effect' import { z } from 'zod' -import { handlerGen } from './handler' -import { middlewareGen } from './middleware' -import * as EffectModule from './runtime' + +import type { EffectWrapOptions, WithEffectContext } from './context.ts' +import { handlerGen } from './handler.ts' +import { middlewareGen } from './middleware.ts' +import * as EffectModule from './runtime.ts' const runPromiseSpy = vi.spyOn(EffectModule, 'runPromise') @@ -31,24 +32,28 @@ describe('middlewareGen', () => { it('continues the chain and can extend the context', async () => { const procedure = os .$context<{ auth: boolean }>() - .use(middlewareGen(function* ({ next }) { - const random = yield* Effect.sync(() => 42) - return yield* next({ context: { random } }) - })) + .use( + middlewareGen(function* ({ next }) { + const random = yield* Effect.sync(() => 42) + return yield* next({ context: { random } }) + }), + ) .handler(({ context }) => `output:${context.random}`) - await expect( - call(procedure, undefined, { context: { auth: true } }), - ).resolves.toEqual('output:42') + await expect(call(procedure, undefined, { context: { auth: true } })).resolves.toEqual( + 'output:42', + ) }) it('can terminate the chain early with done', async () => { const handlerFn = vi.fn(() => 'handler') const procedure = os - .use(middlewareGen(function* (_, __, done) { - return done({ output: 'early' }) - })) + .use( + middlewareGen(function* (_, __, done) { + return done({ output: 'early' }) + }), + ) .handler(handlerFn) await expect(call(procedure)).resolves.toEqual('early') @@ -59,11 +64,13 @@ describe('middlewareGen', () => { const error = new Error('__DOWNSTREAM__') const procedure = os - .use(middlewareGen(function* ({ next }, _, done) { - return yield* next().pipe( - Effect.catch(() => Effect.sync(() => done({ output: 'recovered' }))), - ) - })) + .use( + middlewareGen(function* ({ next }, _, done) { + return yield* next().pipe( + Effect.catch(() => Effect.sync(() => done({ output: 'recovered' }))), + ) + }), + ) .handler(() => { throw error }) @@ -75,9 +82,11 @@ describe('middlewareGen', () => { const error = new Error('__DOWNSTREAM__') const procedure = os - .use(middlewareGen(function* ({ next }) { - return yield* next() - })) + .use( + middlewareGen(function* ({ next }) { + return yield* next() + }), + ) .handler(() => { throw error }) @@ -89,45 +98,49 @@ describe('middlewareGen', () => { const error = new Error('__MIDDLEWARE__') const procedure = os - .use(middlewareGen(function* ({ next }) { - yield* Effect.fail(error) - return yield* next() - })) + .use( + middlewareGen(function* ({ next }) { + yield* Effect.fail(error) + return yield* next() + }), + ) .handler(() => 'unreachable') await expect(call(procedure)).rejects.toBe(error) }) it('propagates ORPCError thrown by the generator', async () => { - const middleware = os - .errors({ UNAUTHORIZED: {} }) - .middleware(middlewareGen(function* ({ errors }) { + const middleware = os.errors({ UNAUTHORIZED: {} }).middleware( + middlewareGen(function* ({ errors }) { throw errors.UNAUTHORIZED() - })) + }), + ) - const procedure = os - .use(middleware) - .handler(() => 'unreachable') + const procedure = os.use(middleware).handler(() => 'unreachable') await expect(call(procedure)).rejects.toSatisfy( - error => error instanceof ORPCError && error.code === 'UNAUTHORIZED' && error.defined, + (error) => error instanceof ORPCError && error.code === 'UNAUTHORIZED' && error.defined, ) }) it('can access effect services from effect/context', async () => { const procedure = os .$context>() - .use(middlewareGen(function* ({ next }) { - const service1 = yield* Service1 - return yield* next({ context: { fromService: service1.id } }) - })) + .use( + middlewareGen(function* ({ next }) { + const service1 = yield* Service1 + return yield* next({ context: { fromService: service1.id } }) + }), + ) .handler(({ context }) => `output:${context.fromService}`) - await expect(call( - procedure, - undefined, - { context: { 'effect/context': Context.empty().pipe(Context.add(Service1, { id: 'Service1' })) } }, - )).resolves.toEqual('output:Service1') + await expect( + call(procedure, undefined, { + context: { + 'effect/context': Context.empty().pipe(Context.add(Service1, { id: 'Service1' })), + }, + }), + ).resolves.toEqual('output:Service1') }) it('can wrap effect execution via effect/wrap', async () => { @@ -137,19 +150,23 @@ describe('middlewareGen', () => { .$context>() .input(z.object({ id: z.string().transform(Number) })) .errors({ BASE: {} }) - .use(middlewareGen(function* ({ next }) { - return yield* next() - })) + .use( + middlewareGen(function* ({ next }) { + return yield* next() + }), + ) .handler(() => 'output') const signal = AbortSignal.timeout(100) const context = { 'effect/context': Context.empty(), 'effect/wrap': wrap } - await expect(call( - procedure, - { id: '1' }, - { path: ['wrapped', 'procedure'], context, signal, lastEventId: 'id' }, - )).resolves.toEqual('output') + await expect( + call( + procedure, + { id: '1' }, + { path: ['wrapped', 'procedure'], context, signal, lastEventId: 'id' }, + ), + ).resolves.toEqual('output') expect(wrap).toHaveBeenCalledExactlyOnceWith(expect.anything(), { kind: 'middleware', @@ -170,16 +187,22 @@ describe('middlewareGen', () => { const procedure = os .$context>() - .use(middlewareGen(function* ({ next }) { - return yield* next({ context: { first: true } }) - })) + .use( + middlewareGen(function* ({ next }) { + return yield* next({ context: { first: true } }) + }), + ) .use(({ next }) => next({ context: { plain: true } })) - .use(middlewareGen(function* ({ next }) { - return yield* next({ context: { second: true } }) - })) - .handler(handlerGen(function* () { - return 'output' - })) + .use( + middlewareGen(function* ({ next }) { + return yield* next({ context: { second: true } }) + }), + ) + .handler( + handlerGen(function* () { + return 'output' + }), + ) await expect(call(procedure, undefined, { context })).resolves.toEqual('output') @@ -195,22 +218,32 @@ describe('middlewareGen', () => { const procedure = os .$context>() - .use(middlewareGen(function* ({ next }) { - return yield* next() - })) + .use( + middlewareGen(function* ({ next }) { + return yield* next() + }), + ) .handler(() => { throw downstreamError }) - await expect(call(procedure, undefined, { - context: { - 'effect/context': Context.empty(), - 'effect/wrap': (effect, opts) => effect.pipe( - Effect.mapError(cause => new ORPCError('BAD_REQUEST', { message: opts.kind, cause })), - ), - }, - })).rejects.toSatisfy( - error => error instanceof ORPCError && error.message === 'middleware' && error.cause === downstreamError, + await expect( + call(procedure, undefined, { + context: { + 'effect/context': Context.empty(), + 'effect/wrap': (effect, opts) => + effect.pipe( + Effect.mapError( + (cause) => new ORPCError('BAD_REQUEST', { message: opts.kind, cause }), + ), + ), + }, + }), + ).rejects.toSatisfy( + (error) => + error instanceof ORPCError && + error.message === 'middleware' && + error.cause === downstreamError, ) }) @@ -221,35 +254,38 @@ describe('middlewareGen', () => { log = [] }) - const tracked = (name: string) => middlewareGen(function* ({ next }) { - return yield* Effect.acquireUseRelease( - Effect.sync(() => log.push(`${name}-acquire`)), - () => next(), - () => Effect.sync(() => log.push(`${name}-release`)), - ) - }) + const tracked = (name: string) => + middlewareGen(function* ({ next }) { + return yield* Effect.acquireUseRelease( + Effect.sync(() => log.push(`${name}-acquire`)), + () => next(), + () => Effect.sync(() => log.push(`${name}-release`)), + ) + }) const timeoutFallback = middlewareGen(function* ({ next }, _, done) { return yield* next().pipe( Effect.timeout('20 millis'), - Effect.catchIf(Cause.isTimeoutError, () => Effect.sync(() => { - log.push('mw-timeout') - return done({ output: 'fallback' }) - })), + Effect.catchIf(Cause.isTimeoutError, () => + Effect.sync(() => { + log.push('mw-timeout') + return done({ output: 'fallback' }) + }), + ), ) }) it('waits for the downstream chain before running finalizers when the request aborts', async () => { - const procedure = os - .use(tracked('mw')) - .handler(async ({ signal }) => { - await sleep(50) - log.push(`handler-done:aborted=${signal?.aborted}`) - return 1 - }) + const procedure = os.use(tracked('mw')).handler(async ({ signal }) => { + await sleep(50) + log.push(`handler-done:aborted=${signal?.aborted}`) + return 1 + }) const signal = AbortSignal.timeout(10) - await expect(call(procedure, undefined, { signal })).rejects.toSatisfy(error => error === signal.reason) + await expect(call(procedure, undefined, { signal })).rejects.toSatisfy( + (error) => error === signal.reason, + ) expect(log).toEqual(['mw-acquire', 'handler-done:aborted=true', 'mw-release']) }) @@ -259,8 +295,7 @@ describe('middlewareGen', () => { .use(async ({ next }) => { try { return await next() - } - finally { + } finally { log.push('plain-finally') } }) @@ -271,7 +306,9 @@ describe('middlewareGen', () => { return 1 }) - await expect(call(procedure, undefined, { signal: AbortSignal.timeout(10) })).rejects.toThrow() + await expect( + call(procedure, undefined, { signal: AbortSignal.timeout(10) }), + ).rejects.toThrow() expect(log).toEqual([ 'outer-acquire', 'inner-acquire', @@ -284,47 +321,51 @@ describe('middlewareGen', () => { it('interrupts a downstream handlerGen when the request aborts', async () => { const procedure = os - .use(middlewareGen(function* ({ next }) { - return yield* next().pipe( - Effect.ensuring(Effect.sync(() => log.push('mw-release'))), - ) - })) - .handler(handlerGen(function* () { - return yield* Effect.never.pipe( - Effect.onInterrupt(() => Effect.sleep('10 millis').pipe( - Effect.andThen(Effect.sync(() => log.push('handler-interrupted'))), - )), - ) - })) + .use( + middlewareGen(function* ({ next }) { + return yield* next().pipe(Effect.ensuring(Effect.sync(() => log.push('mw-release')))) + }), + ) + .handler( + handlerGen(function* () { + return yield* Effect.never.pipe( + Effect.onInterrupt(() => + Effect.sleep('10 millis').pipe( + Effect.andThen(Effect.sync(() => log.push('handler-interrupted'))), + ), + ), + ) + }), + ) const signal = AbortSignal.timeout(10) - await expect(call(procedure, undefined, { signal })).rejects.toSatisfy(error => error === signal.reason) + await expect(call(procedure, undefined, { signal })).rejects.toSatisfy( + (error) => error === signal.reason, + ) expect(log).toEqual(['handler-interrupted', 'mw-release']) }) it('interrupts a downstream handlerGen when Effect.timeout interrupts next', async () => { - const procedure = os - .use(timeoutFallback) - .handler(handlerGen(function* () { + const procedure = os.use(timeoutFallback).handler( + handlerGen(function* () { yield* Effect.sleep('1 second').pipe( Effect.onInterrupt(() => Effect.sync(() => log.push('handler-interrupted'))), ) return 'handler' - })) + }), + ) await expect(call(procedure)).resolves.toEqual('fallback') expect(log).toEqual(['handler-interrupted', 'mw-timeout']) }) it('aborts the signal of a plain handler and waits for it when Effect.timeout interrupts next', async () => { - const procedure = os - .use(timeoutFallback) - .handler(async ({ signal }) => { - await new Promise(resolve => signal?.addEventListener('abort', resolve)) - await sleep(10) - log.push('handler-aborted') - throw new Error('dropped after interruption') - }) + const procedure = os.use(timeoutFallback).handler(async ({ signal }) => { + await new Promise((resolve) => signal?.addEventListener('abort', resolve)) + await sleep(10) + log.push('handler-aborted') + throw new Error('dropped after interruption') + }) await expect(call(procedure)).resolves.toEqual('fallback') expect(log).toEqual(['handler-aborted', 'mw-timeout']) @@ -335,9 +376,11 @@ describe('middlewareGen', () => { let handlerSignal: AbortSignal | undefined const procedure = os - .use(middlewareGen(function* ({ next }) { - return yield* next({ signal: controller.signal }) - })) + .use( + middlewareGen(function* ({ next }) { + return yield* next({ signal: controller.signal }) + }), + ) .handler(({ signal }) => { handlerSignal = signal return 'output' @@ -354,20 +397,24 @@ describe('middlewareGen', () => { describe('effect context propagation', () => { it('propagates services, spans and log annotations around next to downstream effects', async () => { const procedure = os - .use(middlewareGen(function* ({ next }) { - return yield* next().pipe( - Effect.provideService(Service1, { id: 'Service1' }), - Effect.withSpan('mw-span'), - Effect.annotateLogs({ requestId: 'req-1' }), - ) - })) - .handler(handlerGen(function* () { - const service = yield* Effect.serviceOption(Service1) - const span = yield* Effect.currentSpan - const annotations = yield* References.CurrentLogAnnotations - - return { service: Option.getOrUndefined(service)?.id, span: span.name, annotations } - })) + .use( + middlewareGen(function* ({ next }) { + return yield* next().pipe( + Effect.provideService(Service1, { id: 'Service1' }), + Effect.withSpan('mw-span'), + Effect.annotateLogs({ requestId: 'req-1' }), + ) + }), + ) + .handler( + handlerGen(function* () { + const service = yield* Effect.serviceOption(Service1) + const span = yield* Effect.currentSpan + const annotations = yield* References.CurrentLogAnnotations + + return { service: Option.getOrUndefined(service)?.id, span: span.name, annotations } + }), + ) await expect(call(procedure)).resolves.toEqual({ service: 'Service1', @@ -379,48 +426,66 @@ describe('middlewareGen', () => { it('passes the propagated context through non-effect middleware', async () => { const procedure = os .$context>() - .use(middlewareGen(function* ({ next }) { - return yield* next().pipe(Effect.provideService(Service1, { id: 'Service1' })) - })) - .use(({ context, next }) => next({ - context: { 'effect/context': context['effect/context'].pipe(Context.add(Service2, { id: 'Service2' })) }, - })) - .handler(handlerGen(function* () { - const service1 = yield* Effect.serviceOption(Service1) - const service2 = yield* Effect.serviceOption(Service2) - - return [Option.getOrUndefined(service1)?.id, Option.getOrUndefined(service2)?.id] - })) - - await expect(call(procedure, undefined, { context: { 'effect/context': Context.empty() } })) - .resolves - .toEqual(['Service1', 'Service2']) + .use( + middlewareGen(function* ({ next }) { + return yield* next().pipe(Effect.provideService(Service1, { id: 'Service1' })) + }), + ) + .use(({ context, next }) => + next({ + context: { + 'effect/context': context['effect/context'].pipe( + Context.add(Service2, { id: 'Service2' }), + ), + }, + }), + ) + .handler( + handlerGen(function* () { + const service1 = yield* Effect.serviceOption(Service1) + const service2 = yield* Effect.serviceOption(Service2) + + return [Option.getOrUndefined(service1)?.id, Option.getOrUndefined(service2)?.id] + }), + ) + + await expect( + call(procedure, undefined, { context: { 'effect/context': Context.empty() } }), + ).resolves.toEqual(['Service1', 'Service2']) }) it('layers services passed to next over the fiber context', async () => { const procedure = os .$context>() - .use(middlewareGen(function* ({ context, next }) { - return yield* next({ - context: { 'effect/context': context['effect/context'].pipe(Context.add(Service2, { id: 'from-next' })) }, - }).pipe( - Effect.provideService(Service2, { id: 'from-fiber' }), - Effect.annotateLogs({ requestId: 'req-1' }), - ) - })) - .handler(handlerGen(function* () { - const service1 = yield* Service1 - const service2 = yield* Service2 - const annotations = yield* References.CurrentLogAnnotations - - return { service1: service1.id, service2: service2.id, annotations } - })) + .use( + middlewareGen(function* ({ context, next }) { + return yield* next({ + context: { + 'effect/context': context['effect/context'].pipe( + Context.add(Service2, { id: 'from-next' }), + ), + }, + }).pipe( + Effect.provideService(Service2, { id: 'from-fiber' }), + Effect.annotateLogs({ requestId: 'req-1' }), + ) + }), + ) + .handler( + handlerGen(function* () { + const service1 = yield* Service1 + const service2 = yield* Service2 + const annotations = yield* References.CurrentLogAnnotations + + return { service1: service1.id, service2: service2.id, annotations } + }), + ) - await expect(call( - procedure, - undefined, - { context: { 'effect/context': Context.make(Service1, { id: 'Service1' }) } }, - )).resolves.toEqual({ + await expect( + call(procedure, undefined, { + context: { 'effect/context': Context.make(Service1, { id: 'Service1' }) }, + }), + ).resolves.toEqual({ service1: 'Service1', service2: 'from-next', annotations: { requestId: 'req-1' }, @@ -430,32 +495,39 @@ describe('middlewareGen', () => { it('keeps the inner span and annotations when a nested middleware passes effect/context to next', async () => { const procedure = os .$context>() - .use(middlewareGen(function* ({ next }) { - return yield* next().pipe( - Effect.withSpan('outer'), - Effect.annotateLogs({ who: 'outer' }), - ) - })) - .use(middlewareGen(function* ({ context, next }) { - return yield* next({ - context: { 'effect/context': context['effect/context'].pipe(Context.add(Service2, { id: 'Service2' })) }, - }).pipe( - Effect.withSpan('inner'), - Effect.annotateLogs({ who: 'inner' }), - ) - })) - .handler(handlerGen(function* () { - const span = yield* Effect.currentSpan - const annotations = yield* References.CurrentLogAnnotations + .use( + middlewareGen(function* ({ next }) { + return yield* next().pipe( + Effect.withSpan('outer'), + Effect.annotateLogs({ who: 'outer' }), + ) + }), + ) + .use( + middlewareGen(function* ({ context, next }) { + return yield* next({ + context: { + 'effect/context': context['effect/context'].pipe( + Context.add(Service2, { id: 'Service2' }), + ), + }, + }).pipe(Effect.withSpan('inner'), Effect.annotateLogs({ who: 'inner' })) + }), + ) + .handler( + handlerGen(function* () { + const span = yield* Effect.currentSpan + const annotations = yield* References.CurrentLogAnnotations - return { service2: (yield* Service2).id, span: span.name, annotations } - })) + return { service2: (yield* Service2).id, span: span.name, annotations } + }), + ) - await expect(call( - procedure, - undefined, - { context: { 'effect/context': Context.make(Service1, { id: 'Service1' }) } }, - )).resolves.toEqual({ + await expect( + call(procedure, undefined, { + context: { 'effect/context': Context.make(Service1, { id: 'Service1' }) }, + }), + ).resolves.toEqual({ service2: 'Service2', span: 'inner', annotations: { who: 'inner' }, @@ -467,61 +539,92 @@ describe('middlewareGen', () => { const procedure = os .$context>() - .use(middlewareGen(function* ({ context, next }) { - return yield* next({ - context: { 'effect/context': context['effect/context'].pipe(Context.add(Marker, undefined)) }, - }) - })) - .handler(handlerGen(function* () { - return Option.isSome(yield* Effect.serviceOption(Marker)) - })) - - await expect(call(procedure, undefined, { context: { 'effect/context': Context.empty() } })).resolves.toBe(true) + .use( + middlewareGen(function* ({ context, next }) { + return yield* next({ + context: { + 'effect/context': context['effect/context'].pipe(Context.add(Marker, undefined)), + }, + }) + }), + ) + .handler( + handlerGen(function* () { + return Option.isSome(yield* Effect.serviceOption(Marker)) + }), + ) + + await expect( + call(procedure, undefined, { context: { 'effect/context': Context.empty() } }), + ).resolves.toBe(true) }) it('lets effect/context passed to next override an incoming service', async () => { const procedure = os .$context>() - .use(middlewareGen(function* ({ context, next }) { - return yield* next({ - context: { 'effect/context': context['effect/context'].pipe(Context.add(Service2, { id: 'overridden' })) }, - }) - })) - .handler(handlerGen(function* () { - return (yield* Service2).id - })) - - await expect(call( - procedure, - undefined, - { context: { 'effect/context': Context.make(Service2, { id: 'incoming' }) } }, - )).resolves.toEqual('overridden') + .use( + middlewareGen(function* ({ context, next }) { + return yield* next({ + context: { + 'effect/context': context['effect/context'].pipe( + Context.add(Service2, { id: 'overridden' }), + ), + }, + }) + }), + ) + .handler( + handlerGen(function* () { + return (yield* Service2).id + }), + ) + + await expect( + call(procedure, undefined, { + context: { 'effect/context': Context.make(Service2, { id: 'incoming' }) }, + }), + ).resolves.toEqual('overridden') }) - it.each([false, true])('nests effect/wrap spans of downstream effects under the middleware span (adds a service: %s)', async (addsService) => { - let index = 0 + it.each([false, true])( + 'nests effect/wrap spans of downstream effects under the middleware span (adds a service: %s)', + async (addsService) => { + let index = 0 + + const procedure = os + .$context>() + .use( + middlewareGen(function* ({ context, next }) { + return yield* addsService + ? next({ + context: { + 'effect/context': context['effect/context'].pipe( + Context.add(Service2, { id: 'Service2' }), + ), + }, + }) + : next() + }), + ) + .handler( + handlerGen(function* () { + const span = yield* Effect.currentSpan + const parent = Option.getOrUndefined(span.parent) - const procedure = os - .$context>() - .use(middlewareGen(function* ({ context, next }) { - return yield* (addsService - ? next({ context: { 'effect/context': context['effect/context'].pipe(Context.add(Service2, { id: 'Service2' })) } }) - : next()) - })) - .handler(handlerGen(function* () { - const span = yield* Effect.currentSpan - const parent = Option.getOrUndefined(span.parent) - - return [span.name, parent?._tag === 'Span' ? parent.name : undefined] - })) - - await expect(call(procedure, undefined, { - context: { - 'effect/context': Context.empty(), - 'effect/wrap': effect => effect.pipe(Effect.withSpan(`wrap-${index++}`)), - }, - })).resolves.toEqual(['wrap-1', 'wrap-0']) - }) + return [span.name, parent?._tag === 'Span' ? parent.name : undefined] + }), + ) + + await expect( + call(procedure, undefined, { + context: { + 'effect/context': Context.empty(), + 'effect/wrap': (effect) => effect.pipe(Effect.withSpan(`wrap-${index++}`)), + }, + }), + ).resolves.toEqual(['wrap-1', 'wrap-0']) + }, + ) }) it('forwards effect/context and the provided signal to runPromise', async () => { @@ -530,16 +633,24 @@ describe('middlewareGen', () => { const procedure = os .$context>() - .use(middlewareGen(function* ({ next }) { - return yield* next() - })) + .use( + middlewareGen(function* ({ next }) { + return yield* next() + }), + ) .handler(() => 'output') await expect( - call(procedure, undefined, { context: { 'effect/context': effectContext }, signal: controller.signal }), + call(procedure, undefined, { + context: { 'effect/context': effectContext }, + signal: controller.signal, + }), ).resolves.toEqual('output') expect(runPromiseSpy).toHaveBeenCalledTimes(1) - expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { context: effectContext, signal: controller.signal }) + expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { + context: effectContext, + signal: controller.signal, + }) }) }) diff --git a/packages/effect/src/middleware.ts b/packages/effect/src/middleware.ts index 568898cd26..02dc3511d0 100644 --- a/packages/effect/src/middleware.ts +++ b/packages/effect/src/middleware.ts @@ -1,10 +1,19 @@ import type { ErrorMap } from '@orpc/contract' -import type { Context, Middleware, MiddlewareDone, MiddlewareNextOptions, MiddlewareOptions, MiddlewareResult, ORPCErrorConstructorMap } from '@orpc/server' +import type { + Context, + Middleware, + MiddlewareDone, + MiddlewareNextOptions, + MiddlewareOptions, + MiddlewareResult, + ORPCErrorConstructorMap, +} from '@orpc/server' import type { MaybeOptionalOptions } from '@orpc/shared' -import type { InferEffectServices, WithEffectContext } from './context' import { anyAbortSignal, resolveMaybeOptionalOptions } from '@orpc/shared' import { Effect, Context as EffectContext } from 'effect' -import { runPromise } from './runtime' + +import type { InferEffectServices, WithEffectContext } from './context.ts' +import { runPromise } from './runtime.ts' export interface MiddlewareGenNext { ( @@ -32,11 +41,7 @@ export interface MiddlewareGen< opts: MiddlewareGenOptions, input: TInput, done: MiddlewareDone, - ): Generator< - TYield, - MiddlewareResult, - never - > + ): Generator, never> } export type AnyMiddlewareGen = MiddlewareGen @@ -55,36 +60,65 @@ export function middlewareGen< TInput = unknown, TOutput = any, // TOutput = any by default is important to make middleware can be used in any output by default TErrorMap extends ErrorMap = Record, - TYield extends Effect.Effect> = Effect.Effect>, + TYield extends Effect.Effect> = Effect.Effect< + any, + any, + InferEffectServices + >, >( - middleware: MiddlewareGen, TYield>, + middleware: MiddlewareGen< + TInContext, + TOutContext, + TInput, + TOutput, + ORPCErrorConstructorMap, + TYield + >, ): Middleware { - const mid: Middleware = (opts, input, done) => { - const next: MiddlewareGenNext = (...rest: MaybeOptionalOptions>) => Effect.contextWith( - fiberContext => Effect.callback>, unknown>((resume, interruptSignal) => { - const nextOptions = resolveMaybeOptionalOptions(rest) - - const promise = Promise.resolve(opts.next({ - context: { - ...nextOptions.context, - 'effect/context': layerChangedServices(fiberContext, nextOptions.context?.['effect/context'], opts.context['effect/context']), + const mid: Middleware = ( + opts, + input, + done, + ) => { + const next: MiddlewareGenNext = ( + ...rest: MaybeOptionalOptions> + ) => + Effect.contextWith((fiberContext) => + Effect.callback>, unknown>( + (resume, interruptSignal) => { + const nextOptions = resolveMaybeOptionalOptions(rest) + + const promise = Promise.resolve( + opts.next({ + context: { + ...nextOptions.context, + 'effect/context': layerChangedServices( + fiberContext, + nextOptions.context?.['effect/context'], + opts.context['effect/context'], + ), + }, + signal: anyAbortSignal([nextOptions.signal ?? opts.signal, interruptSignal]), + } as any), + ) + + promise.then( + (result) => resume(Effect.succeed(result)), + (error) => resume(Effect.fail(error)), + ) + + // On interruption, wait for the chain to settle. + return Effect.promise(() => promise.catch(() => {})) }, - signal: anyAbortSignal([nextOptions.signal ?? opts.signal, interruptSignal]), - } as any)) - - promise.then( - result => resume(Effect.succeed(result)), - error => resume(Effect.fail(error)), - ) - - // On interruption, wait for the chain to settle. - return Effect.promise(() => promise.catch(() => {})) - }), - ) + ), + ) const effectContext = opts.context['effect/context'] const wrap = opts.context['effect/wrap'] as WithEffectContext['effect/wrap'] - const ef = Effect.gen(() => middleware({ ...opts, next }, input, done)) as Effect.Effect, unknown> + const ef = Effect.gen(() => middleware({ ...opts, next }, input, done)) as Effect.Effect< + MiddlewareResult, + unknown + > let wrapped = ef if (typeof wrap === 'function') { @@ -107,7 +141,11 @@ export function middlewareGen< * An `effect/context` passed to `next` is the incoming one plus changes, so only the changed services * are layered over the fiber context, keeping the fiber's newer services, spans and log annotations. */ -function layerChangedServices(fiberContext: EffectContext.Context, nextServices: unknown, incomingServices: unknown): EffectContext.Context { +function layerChangedServices( + fiberContext: EffectContext.Context, + nextServices: unknown, + incomingServices: unknown, +): EffectContext.Context { if (!EffectContext.isContext(nextServices) || nextServices === incomingServices) { return fiberContext } @@ -115,7 +153,11 @@ function layerChangedServices(fiberContext: EffectContext.Context, nextSe const services = new Map(fiberContext.mapUnsafe) for (const [key, service] of nextServices.mapUnsafe) { - if (!EffectContext.isContext(incomingServices) || !incomingServices.mapUnsafe.has(key) || incomingServices.mapUnsafe.get(key) !== service) { + if ( + !EffectContext.isContext(incomingServices) || + !incomingServices.mapUnsafe.has(key) || + incomingServices.mapUnsafe.get(key) !== service + ) { services.set(key, service) } } diff --git a/packages/effect/src/runtime.test.ts b/packages/effect/src/runtime.test.ts index 384b8adbd3..f81392f128 100644 --- a/packages/effect/src/runtime.test.ts +++ b/packages/effect/src/runtime.test.ts @@ -1,7 +1,8 @@ import { AbortError } from '@orpc/shared' import { Cause, Context, Effect, Option, Scheduler } from 'effect' import { describe, expect, it, vi } from 'vitest' -import { runPromise } from './runtime' + +import { runPromise } from './runtime.ts' describe('runPromise & extractErrorFromCause', () => { describe('success', () => { @@ -23,7 +24,9 @@ describe('runPromise & extractErrorFromCause', () => { it('runs the effect with the provided services', async () => { const context = Context.empty().pipe(Context.add(Service1, { id: 'Service1' })) - await expect(runPromise(Effect.serviceOption(Service1), { context })).resolves.toEqual(Option.some({ id: 'Service1' })) + await expect(runPromise(Effect.serviceOption(Service1), { context })).resolves.toEqual( + Option.some({ id: 'Service1' }), + ) }) it('runs the effect with the provided scheduler', async () => { @@ -39,9 +42,9 @@ describe('runPromise & extractErrorFromCause', () => { describe('failure - throws original error without FiberFailure wrapper', () => { it('throws signal reason when interrupted with signal', async () => { const signal = AbortSignal.timeout(0) - await expect( - runPromise(Effect.never, { signal }), - ).rejects.toSatisfy(e => e === signal.reason) + await expect(runPromise(Effect.never, { signal })).rejects.toSatisfy( + (e) => e === signal.reason, + ) }) it('throws a generic AbortError when interrupted without signal', async () => { @@ -49,7 +52,9 @@ describe('runPromise & extractErrorFromCause', () => { yield* Effect.interrupt }) - await expect(runPromise(effect)).rejects.toThrow(new AbortError('All fibers interrupted without error')) + await expect(runPromise(effect)).rejects.toThrow( + new AbortError('All fibers interrupted without error'), + ) }) it('throws the original defect from Effect.die', async () => { @@ -72,7 +77,7 @@ describe('runPromise & extractErrorFromCause', () => { const effect = Effect.acquireUseRelease( Effect.succeed('resource'), () => Effect.fail(useError), - () => Effect.fail(new Error('finalizer also failed')) as Effect.Effect, + () => Effect.fail(new Error('finalizer also failed')) as Effect.Effect, ) await expect(runPromise(effect)).rejects.toThrow(useError) @@ -82,10 +87,9 @@ describe('runPromise & extractErrorFromCause', () => { 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' }, - ) + const effect = Effect.all([Effect.fail(leftError), Effect.fail(rightError)], { + concurrency: 'unbounded', + }) await expect(runPromise(effect)).rejects.toThrow(leftError) }) @@ -98,9 +102,7 @@ describe('runPromise & extractErrorFromCause', () => { it('throws a empty Error when cause is empty', async () => { const effect = Effect.failCause(Cause.empty) - await expect( - runPromise(effect), - ).rejects.toThrow(new Error('Empty cause')) + await expect(runPromise(effect)).rejects.toThrow(new Error('Empty cause')) }) }) }) diff --git a/packages/effect/src/runtime.ts b/packages/effect/src/runtime.ts index 84d57264b5..67d48172f7 100644 --- a/packages/effect/src/runtime.ts +++ b/packages/effect/src/runtime.ts @@ -11,7 +11,10 @@ export interface RunPromiseOptions extends Effect.RunOptions { /** * Runs an Effect as a Promise and throws the most meaningful errors. */ -export async function runPromise(effect: Effect.Effect, options: RunPromiseOptions = {}): Promise { +export async function runPromise( + effect: Effect.Effect, + options: RunPromiseOptions = {}, +): Promise { const exit = await Effect.runPromiseExitWith(options.context ?? Context.empty())(effect, options) if (Exit.isSuccess(exit)) { diff --git a/packages/effect/src/schema.test.ts b/packages/effect/src/schema.test.ts index f13e3187e5..0272116322 100644 --- a/packages/effect/src/schema.test.ts +++ b/packages/effect/src/schema.test.ts @@ -1,6 +1,7 @@ import { getHiddenMetaPlugins, setHiddenMetaPlugins } from '@orpc/contract' import { Schema } from 'effect' -import { toStandardSchema } from './schema' + +import { toStandardSchema } from './schema.ts' describe('toStandardSchema', () => { it('convert to standard schema', () => { diff --git a/packages/effect/tsconfig.json b/packages/effect/tsconfig.json index fe827abfee..9979a0715c 100644 --- a/packages/effect/tsconfig.json +++ b/packages/effect/tsconfig.json @@ -1,19 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../contract" }, - { "path": "../server" }, - { "path": "../json-schema" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/*.bench.*", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/evlog/README.md b/packages/evlog/README.md index 55d76e76de..6cd0705ce3 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/evlog/build.config.ts b/packages/evlog/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/evlog/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/evlog/package.json b/packages/evlog/package.json index 0507794e99..07eed48f42 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -1,57 +1,53 @@ { "name": "@orpc/evlog", - "type": "module", "version": "2.0.0-beta.48", "description": "Evlog integration for oRPC: wide-event logging for typesafe APIs", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "evlog", + "logging", + "observability", + "orpc", + "typescript", + "wide-events" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/evlog" }, - "keywords": [ - "orpc", - "evlog", - "logging", - "wide-events", - "observability", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./node": "./src/adapters/node.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./node": { - "types": "./dist/adapters/node.d.mts", - "import": "./dist/adapters/node.mjs", - "default": "./dist/adapters/node.mjs" + "types": "./dist/adapters/node.d.ts", + "import": "./dist/adapters/node.js", + "default": "./dist/adapters/node.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts", - "./node": "./src/adapters/node.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "evlog": ">=2.18.1" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", @@ -61,5 +57,8 @@ }, "devDependencies": { "evlog": "^2.34.0" + }, + "peerDependencies": { + "evlog": ">=2.18.1" } } diff --git a/packages/evlog/src/adapters/node.test.ts b/packages/evlog/src/adapters/node.test.ts index 8a070e6061..6bc3eaaa9f 100644 --- a/packages/evlog/src/adapters/node.test.ts +++ b/packages/evlog/src/adapters/node.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { createLoggerStorage } from './node' + +import { createLoggerStorage } from './node.ts' describe('createLoggerStorage', () => { it('exposes useLogger backed by the storage', () => { diff --git a/packages/evlog/src/context.test.ts b/packages/evlog/src/context.test.ts index 2439d752d1..b90cc81c7a 100644 --- a/packages/evlog/src/context.test.ts +++ b/packages/evlog/src/context.test.ts @@ -1,5 +1,6 @@ import { createRequestLogger } from 'evlog' -import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context' + +import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context.ts' it('getLogger', async () => { expect(getLogger({})).toBeUndefined() diff --git a/packages/evlog/src/context.ts b/packages/evlog/src/context.ts index e522093182..b73bba2633 100644 --- a/packages/evlog/src/context.ts +++ b/packages/evlog/src/context.ts @@ -1,6 +1,8 @@ import type { RequestLogger } from 'evlog' -export const LOGGER_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for('ORPC_EVLOG_LOGGER_CONTEXT') +export const LOGGER_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for( + 'ORPC_EVLOG_LOGGER_CONTEXT', +) /** * Context shape that carries the Evlog request logger injected by `EvlogHandlerPlugin`. diff --git a/packages/evlog/src/handler-plugin.test.ts b/packages/evlog/src/handler-plugin.test.ts index e73a1d9ad0..172a456743 100644 --- a/packages/evlog/src/handler-plugin.test.ts +++ b/packages/evlog/src/handler-plugin.test.ts @@ -1,10 +1,11 @@ -import type { LogLevel } from 'evlog' import { ORPCError, RPCSerializer } from '@orpc/client' import { AbortError, ORPC_NAME, sleep } from '@orpc/shared' import { ErrorEvent } from '@standard-server/core' +import type { LogLevel } from 'evlog' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { LOGGER_CONTEXT_SYMBOL } from './context' -import { EvlogHandlerPlugin } from './handler-plugin' + +import { LOGGER_CONTEXT_SYMBOL } from './context.ts' +import { EvlogHandlerPlugin } from './handler-plugin.ts' const mocks = vi.hoisted(() => ({ defineFrameworkIntegration: vi.fn(), @@ -24,7 +25,7 @@ vi.mock('evlog/toolkit', () => ({ function createRequest( url: string, - options: { headers?: Record, signal?: AbortSignal } = {}, + options: { headers?: Record; signal?: AbortSignal } = {}, ) { return { method: 'GET', @@ -119,11 +120,13 @@ describe('evlogHandlerPlugin', () => { 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' }, + expect( + mocks.spec.extractRequest({ + request: createRequest('/hello/world?debug=1', { + headers: { 'x-request-id': 'request-id' }, + }), }), - })).toEqual({ + ).toEqual({ method: 'GET', path: '/hello/world', headers: { 'x-request-id': 'request-id' }, @@ -137,14 +140,8 @@ describe('evlogHandlerPlugin', () => { clientInterceptors: [existingClientInterceptor], } as any) - expect(initialized.routingInterceptors).toEqual([ - expect.any(Function), - existingRouting, - ]) - expect(initialized.interceptors).toEqual([ - expect.any(Function), - existingInterceptor, - ]) + expect(initialized.routingInterceptors).toEqual([expect.any(Function), existingRouting]) + expect(initialized.interceptors).toEqual([expect.any(Function), existingInterceptor]) expect(initialized.clientInterceptors).toEqual([ expect.any(Function), existingClientInterceptor, @@ -163,16 +160,21 @@ describe('evlogHandlerPlugin', () => { logger: createLogger(), }) - await expect(routing({ - next, - context: { existing: true }, - request: createRequest('/skip'), - })).resolves.toBe('skipped') + 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(mocks.start).toHaveBeenCalledWith( + expect.objectContaining({ + context: { existing: true }, + request: expect.objectContaining({ url: '/skip' }), + }), + {}, + ) expect(next).toHaveBeenCalledWith() }) @@ -233,11 +235,13 @@ describe('evlogHandlerPlugin', () => { 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) + await expect( + routing({ + next: vi.fn().mockRejectedValue(error), + context: {}, + request: createRequest('/ping'), + }), + ).rejects.toThrow(error) expect(logger.error).toHaveBeenCalledWith(error) expect(finish).toHaveBeenCalledWith() @@ -269,7 +273,9 @@ describe('evlogHandlerPlugin', () => { }) expect((result.response.body as typeof iterator).meta).toBe('preserved') - await expect(collectIterator(result.response.body as AsyncIterable)).resolves.toEqual([1, 2]) + await expect(collectIterator(result.response.body as AsyncIterable)).resolves.toEqual([ + 1, 2, + ]) expect(finish).toHaveBeenCalledWith({ status: 200 }) }) @@ -279,7 +285,7 @@ describe('evlogHandlerPlugin', () => { const { finish, logger } = mockIntegration() async function* source() { - // eslint-disable-next-line no-throw-literal + // oxlint-disable-next-line no-throw-literal throw 'iterator-failure' } @@ -295,7 +301,9 @@ describe('evlogHandlerPlugin', () => { request: createRequest('/iterable-error'), }) - await expect(collectIterator(result.response.body as AsyncIterable)).rejects.toBe('iterator-failure') + 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) @@ -322,7 +330,9 @@ describe('evlogHandlerPlugin', () => { request: createRequest('/iterable-error-event'), }) - await expect(collectIterator(result.response.body as AsyncIterable)).rejects.toBeInstanceOf(ErrorEvent) + await expect( + collectIterator(result.response.body as AsyncIterable), + ).rejects.toBeInstanceOf(ErrorEvent) expect(logger.error).not.toHaveBeenCalled() expect(finish).toHaveBeenCalledWith({ status: 200 }) }) @@ -355,7 +365,10 @@ describe('evlogHandlerPlugin', () => { }) 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 expect(collectStream(result.response.body as ReadableStream)).resolves.toEqual([ + 'chunk-1', + 'chunk-2', + ]) await sleep(0) expect(finish).toHaveBeenCalledWith({ status: 206 }) }) @@ -382,7 +395,9 @@ describe('evlogHandlerPlugin', () => { request: createRequest('/stream-error'), }) - await expect(collectStream(result.response.body as ReadableStream)).rejects.toThrow(streamError) + await expect(collectStream(result.response.body as ReadableStream)).rejects.toThrow( + streamError, + ) await sleep(0) expect(logger.error).toHaveBeenCalledWith(streamError) expect(finish).toHaveBeenCalledWith({ status: 200 }) @@ -398,15 +413,17 @@ describe('evlogHandlerPlugin', () => { const plugin = new EvlogHandlerPlugin({ logAbort: true }) const { interceptor } = getPluginHooks(plugin) - await expect(interceptor({ - next: vi.fn(async () => { - controller.abort('manual') - return 'pong' + 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 }), }), - context: { [LOGGER_CONTEXT_SYMBOL]: logger }, - path: ['nested', 'ping'], - request: createRequest('/ping', { signal: controller.signal }), - })).resolves.toBe('pong') + ).resolves.toBe('pong') expect(logger.set).toHaveBeenNthCalledWith(1, { rpc: { system: ORPC_NAME, method: 'nested.ping' }, @@ -421,12 +438,14 @@ describe('evlogHandlerPlugin', () => { 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') + 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: { @@ -442,12 +461,14 @@ describe('evlogHandlerPlugin', () => { const { interceptor } = getPluginHooks(plugin) const businessError = new ORPCError('UNAUTHORIZED') - await expect(interceptor({ - next: vi.fn().mockRejectedValue(businessError), - context: { [LOGGER_CONTEXT_SYMBOL]: logger }, - path: ['ping'], - request: createRequest('/ping'), - })).rejects.toThrow(businessError) + 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).toHaveBeenCalledWith('warn') @@ -457,12 +478,14 @@ describe('evlogHandlerPlugin', () => { const unexpectedError = new Error('boom') - await expect(interceptor({ - next: vi.fn().mockRejectedValue(unexpectedError), - context: { [LOGGER_CONTEXT_SYMBOL]: logger }, - path: ['ping'], - request: createRequest('/ping'), - })).rejects.toThrow(unexpectedError) + await expect( + interceptor({ + next: vi.fn().mockRejectedValue(unexpectedError), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + path: ['ping'], + request: createRequest('/ping'), + }), + ).rejects.toThrow(unexpectedError) expect(logger.error).toHaveBeenCalledWith(unexpectedError) expect(logger.setLevel).not.toHaveBeenCalled() @@ -472,12 +495,14 @@ describe('evlogHandlerPlugin', () => { 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) + 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') @@ -487,12 +512,14 @@ describe('evlogHandlerPlugin', () => { const internalError = new ORPCError('INTERNAL_SERVER_ERROR') - await expect(interceptor({ - next: vi.fn().mockRejectedValue(internalError), - context: { [LOGGER_CONTEXT_SYMBOL]: logger }, - path: ['ping'], - request: createRequest('/ping'), - })).rejects.toThrow(internalError) + await expect( + interceptor({ + next: vi.fn().mockRejectedValue(internalError), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + path: ['ping'], + request: createRequest('/ping'), + }), + ).rejects.toThrow(internalError) expect(logger.error).toHaveBeenCalledWith(internalError) expect(logger.setLevel).not.toHaveBeenCalled() @@ -504,32 +531,35 @@ describe('evlogHandlerPlugin', () => { const { interceptor } = getPluginHooks(plugin) const error = new Error('boom') - await expect(interceptor({ - next: vi.fn().mockRejectedValue(error), - context: {}, - path: ['ping'], - request: createRequest('/ping'), - })).rejects.toThrow(error) + await expect( + interceptor({ + next: vi.fn().mockRejectedValue(error), + context: {}, + path: ['ping'], + request: createRequest('/ping'), + }), + ).rejects.toThrow(error) expect(procedureErrorLevel).not.toHaveBeenCalled() }) it('honors the procedureErrorLevel option, passing the error and its default level', async () => { const logger = createLogger() - const procedureErrorLevel = vi.fn( - (error: unknown, level: LogLevel) => - error instanceof ORPCError && error.code === 'UNAUTHORIZED' ? 'debug' as const : level, + const procedureErrorLevel = vi.fn((error: unknown, level: LogLevel) => + error instanceof ORPCError && error.code === 'UNAUTHORIZED' ? ('debug' as const) : level, ) const plugin = new EvlogHandlerPlugin({ procedureErrorLevel }) const { interceptor } = getPluginHooks(plugin) const businessError = new ORPCError('UNAUTHORIZED') - await expect(interceptor({ - next: vi.fn().mockRejectedValue(businessError), - context: { [LOGGER_CONTEXT_SYMBOL]: logger }, - path: ['ping'], - request: createRequest('/ping'), - })).rejects.toThrow(businessError) + await expect( + interceptor({ + next: vi.fn().mockRejectedValue(businessError), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + path: ['ping'], + request: createRequest('/ping'), + }), + ).rejects.toThrow(businessError) expect(procedureErrorLevel).toHaveBeenCalledWith(businessError, 'warn') expect(logger.error).toHaveBeenCalledWith(businessError) @@ -540,12 +570,14 @@ describe('evlogHandlerPlugin', () => { const otherError = new ORPCError('CONFLICT') - await expect(interceptor({ - next: vi.fn().mockRejectedValue(otherError), - context: { [LOGGER_CONTEXT_SYMBOL]: logger }, - path: ['ping'], - request: createRequest('/ping'), - })).rejects.toThrow(otherError) + await expect( + interceptor({ + next: vi.fn().mockRejectedValue(otherError), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + path: ['ping'], + request: createRequest('/ping'), + }), + ).rejects.toThrow(otherError) expect(procedureErrorLevel).toHaveBeenCalledWith(otherError, 'warn') expect(logger.setLevel).toHaveBeenCalledWith('warn') @@ -555,10 +587,12 @@ describe('evlogHandlerPlugin', () => { const plugin = new EvlogHandlerPlugin() const { client } = getPluginHooks(plugin) - await expect(client({ - next: vi.fn().mockResolvedValue('pong'), - context: {}, - })).resolves.toBe('pong') + await expect( + client({ + next: vi.fn().mockResolvedValue('pong'), + context: {}, + }), + ).resolves.toBe('pong') }) it('logs client AsyncIteratorObject errors', async () => { @@ -588,11 +622,13 @@ describe('evlogHandlerPlugin', () => { const abortError = new AbortError('stream-abort') const output = await client({ - next: vi.fn().mockResolvedValue(new ReadableStream({ - start(controller) { - controller.error(abortError) - }, - })), + next: vi.fn().mockResolvedValue( + new ReadableStream({ + start(controller) { + controller.error(abortError) + }, + }), + ), context: { [LOGGER_CONTEXT_SYMBOL]: logger }, }) diff --git a/packages/evlog/src/handler-plugin.ts b/packages/evlog/src/handler-plugin.ts index ef2867739d..5f1f6492f8 100644 --- a/packages/evlog/src/handler-plugin.ts +++ b/packages/evlog/src/handler-plugin.ts @@ -1,18 +1,37 @@ +import { ORPCError, wrapAsyncIteratorPreservingEventMeta } from '@orpc/client' import type { Context, ErrorMap, ProcedureClientInterceptor, Schema } from '@orpc/server' -import type { StandardHandlerInterceptor, StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '@orpc/server/standard' +import type { + StandardHandlerInterceptor, + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, +} from '@orpc/server/standard' +import { + isAbortError, + isAsyncIteratorObject, + ORPC_NAME, + override, + sleep, + toArray, + wrapReadableStream, +} from '@orpc/shared' import type { StandardRequest } from '@standard-server/core' -import type { LogLevel, RequestLogger } from 'evlog' -import type { BaseEvlogOptions, FrameworkIntegrationHelpers, FrameworkIntegrationSpec } from 'evlog/toolkit' -import { ORPCError, wrapAsyncIteratorPreservingEventMeta } from '@orpc/client' -import { isAbortError, isAsyncIteratorObject, ORPC_NAME, override, sleep, toArray, wrapReadableStream } from '@orpc/shared' import { ErrorEvent, flattenStandardHeader, parseStandardUrl } from '@standard-server/core' +import type { LogLevel, RequestLogger } from 'evlog' +import type { + BaseEvlogOptions, + FrameworkIntegrationHelpers, + FrameworkIntegrationSpec, +} from 'evlog/toolkit' import { defineFrameworkIntegration } from 'evlog/toolkit' -import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context' + +import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context.ts' export interface EvlogHandlerPluginOptions<_T extends Context> extends BaseEvlogOptions { /** * AsyncLocalStorage instance backing `useLogger()`. */ + // oxlint-disable-next-line typescript/no-redundant-type-constituents storage?: FrameworkIntegrationSpec<{ request: StandardRequest }>['storage'] | undefined /** @@ -51,13 +70,19 @@ export class EvlogHandlerPlugin implements StandardHandlerPlu before: string[] = ['~tracing', '~batch', '~hibernation'] private readonly logAbort: Exclude['logAbort'], undefined> - private readonly procedureErrorLevel: Exclude['procedureErrorLevel'], undefined> + private readonly procedureErrorLevel: Exclude< + EvlogHandlerPluginOptions['procedureErrorLevel'], + undefined + > private readonly integration: FrameworkIntegrationHelpers<{ request: StandardRequest }> private readonly evlogOptions: BaseEvlogOptions - constructor( - { storage, logAbort, procedureErrorLevel, ...evlogOptions }: EvlogHandlerPluginOptions = {}, - ) { + constructor({ + storage, + logAbort, + procedureErrorLevel, + ...evlogOptions + }: EvlogHandlerPluginOptions = {}) { this.evlogOptions = evlogOptions this.logAbort = logAbort ?? false this.procedureErrorLevel = procedureErrorLevel ?? ((_, level) => level) @@ -81,21 +106,29 @@ export class EvlogHandlerPlugin implements StandardHandlerPlu } init(options: StandardHandlerOptions): StandardHandlerOptions { - const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ next, ...interceptorOptions }) => { - const { skipped, finish, runWith, logger } = this.integration.start(interceptorOptions, this.evlogOptions) + 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, - }, - })) + const result = await runWith(() => + next({ + ...interceptorOptions, + context: { + ...interceptorOptions.context, + [LOGGER_CONTEXT_SYMBOL]: logger, + }, + }), + ) if (result.matched) { if (isAsyncIteratorObject(result.response.body)) { @@ -107,23 +140,26 @@ export class EvlogHandlerPlugin implements StandardHandlerPlu * @remarks * **Warning**: Remember use `override` for AsyncIteratorObject to remain other special properties */ - body: override(result.response.body, wrapAsyncIteratorPreservingEventMeta(result.response.body, { - runWith, - onError: (error) => { - /** - * Errors here are internal (interceptor/framework) failures, - * except `ErrorEvent`: a business error the protocol delivers - * inside the event stream, already logged by the client interceptor. - */ - if (!(error instanceof ErrorEvent)) { - 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 }) - }, - })), + body: override( + result.response.body, + wrapAsyncIteratorPreservingEventMeta(result.response.body, { + runWith, + onError: (error) => { + /** + * Errors here are internal (interceptor/framework) failures, + * except `ErrorEvent`: a business error the protocol delivers + * inside the event stream, already logged by the client interceptor. + */ + if (!(error instanceof ErrorEvent)) { + 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 }) + }, + }), + ), }, } } @@ -137,33 +173,34 @@ export class EvlogHandlerPlugin implements StandardHandlerPlu * @remarks * **Warning**: Remember use `override` for ReadableStream 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 }) - }, - })), + 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 { + } else { logger.set({ message: 'No procedure matched' }) } await finish({ status: result.response?.status }) return result - } - catch (error) { + } catch (error) { /** * Any error here is internal (interceptor/framework), not business logic. * Indicates unexpected handler failure. @@ -188,29 +225,34 @@ export class EvlogHandlerPlugin implements StandardHandlerPlu reason: String(signal.reason), }, }) - } - else { - signal?.addEventListener('abort', () => { - logger?.set({ - abort: { - reason: String(signal.reason), - abortedAt: new Date().toISOString(), - }, - }) - }, { once: true }) + } else { + signal?.addEventListener( + 'abort', + () => { + logger?.set({ + abort: { + reason: String(signal.reason), + abortedAt: new Date().toISOString(), + }, + }) + }, + { once: true }, + ) } } try { return await next() - } - catch (error) { + } catch (error) { logProcedureError(logger, error, this.procedureErrorLevel) throw error } } - const clientInterceptor: ProcedureClientInterceptor, ErrorMap> = async ({ next, context }) => { + const clientInterceptor: ProcedureClientInterceptor, ErrorMap> = async ({ + next, + context, + }) => { const logger = getLogger(context) const output = await next() @@ -219,11 +261,14 @@ export class EvlogHandlerPlugin implements StandardHandlerPlu * @remarks * **Warning**: Remember use `override` for AsyncIteratorObject to remain other special properties */ - return override(output, wrapAsyncIteratorPreservingEventMeta(output, { - onError: (error) => { - logProcedureError(logger, error, this.procedureErrorLevel) - }, - })) + return override( + output, + wrapAsyncIteratorPreservingEventMeta(output, { + onError: (error) => { + logProcedureError(logger, error, this.procedureErrorLevel) + }, + }), + ) } if (output instanceof ReadableStream) { @@ -231,11 +276,14 @@ export class EvlogHandlerPlugin implements StandardHandlerPlu * @remarks * **Warning**: Remember use `override` for ReadableStream to remain other special properties */ - return override(output, wrapReadableStream(output, { - onError: (error) => { - logProcedureError(logger, error, this.procedureErrorLevel) - }, - })) + return override( + output, + wrapReadableStream(output, { + onError: (error) => { + logProcedureError(logger, error, this.procedureErrorLevel) + }, + }), + ) } return output @@ -243,18 +291,9 @@ export class EvlogHandlerPlugin implements StandardHandlerPlu return { ...options, - routingInterceptors: [ - routingInterceptor, - ...toArray(options.routingInterceptors), - ], - interceptors: [ - interceptor, - ...toArray(options.interceptors), - ], - clientInterceptors: [ - clientInterceptor, - ...toArray(options.clientInterceptors), - ], + routingInterceptors: [routingInterceptor, ...toArray(options.routingInterceptors)], + interceptors: [interceptor, ...toArray(options.interceptors)], + clientInterceptors: [clientInterceptor, ...toArray(options.clientInterceptors)], } } } diff --git a/packages/evlog/src/index.test.ts b/packages/evlog/src/index.test.ts index 7a805f3a3f..5e7003e252 100644 --- a/packages/evlog/src/index.test.ts +++ b/packages/evlog/src/index.test.ts @@ -1,3 +1,3 @@ it('exports EvlogHandlerPlugin', async () => { - expect(Object.keys(await import('./index'))).toContain('EvlogHandlerPlugin') + expect(Object.keys(await import('./index.ts'))).toContain('EvlogHandlerPlugin') }) diff --git a/packages/evlog/src/index.ts b/packages/evlog/src/index.ts index baf5a0d9be..b70e727a08 100644 --- a/packages/evlog/src/index.ts +++ b/packages/evlog/src/index.ts @@ -1,2 +1,2 @@ -export * from './context' -export * from './handler-plugin' +export * from './context.ts' +export * from './handler-plugin.ts' diff --git a/packages/evlog/tsconfig.json b/packages/evlog/tsconfig.json index 075f89b340..9979a0715c 100644 --- a/packages/evlog/tsconfig.json +++ b/packages/evlog/tsconfig.json @@ -1,17 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../server" }, - { "path": "../client" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/*.bench.*", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index b12fb86388..f0e58751e9 100644 --- a/packages/hibernation/README.md +++ b/packages/hibernation/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/hibernation/build.config.ts b/packages/hibernation/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/hibernation/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/hibernation/package.json b/packages/hibernation/package.json index 7414458f55..229a3698f9 100644 --- a/packages/hibernation/package.json +++ b/packages/hibernation/package.json @@ -1,48 +1,47 @@ { "name": "@orpc/hibernation", - "type": "module", "version": "2.0.0-beta.48", "description": "Hibernation API support for oRPC, including Cloudflare Durable Objects' WebSocket Hibernation", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "cloudflare", + "durable-objects", + "hibernation", + "orpc", + "typescript", + "websocket" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/hibernation" }, - "keywords": [ - "orpc", - "hibernation", - "websocket", - "cloudflare", - "durable-objects", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", diff --git a/packages/hibernation/src/encode-event.test.ts b/packages/hibernation/src/encode-event.test.ts index fc9f052b7b..a6419bccd9 100644 --- a/packages/hibernation/src/encode-event.test.ts +++ b/packages/hibernation/src/encode-event.test.ts @@ -2,14 +2,18 @@ import type { RPCJsonSerializerHandler } from '@orpc/client' import { ORPCError, RPCSerializer } from '@orpc/client' import { getEventMeta, withEventMeta } from '@standard-server/core' import { encodePeerMessage } from '@standard-server/peer' -import { encodeHibernationRPCEvent } from './encode-event' + +import { encodeHibernationRPCEvent } from './encode-event.ts' class Planet { - constructor(public readonly name: string, public readonly diameter: number) {} + constructor( + public readonly name: string, + public readonly diameter: number, + ) {} } const planetHandler: RPCJsonSerializerHandler = { - condition: value => value instanceof Planet, + condition: (value) => value instanceof Planet, serialize: (value: Planet) => [value.name, value.diameter], deserialize: ([name, diameter]: [string, number]) => new Planet(name, diameter), } @@ -91,14 +95,19 @@ describe('encodeHibernationRPCEvent', () => { it('with prefix', async () => { const id = '39483' - const encoded = await encodeHibernationRPCEvent(id, 'hello world', { encodePeerMessage: { prefix: 'orpc:' } }) + const encoded = await encodeHibernationRPCEvent(id, 'hello world', { + encodePeerMessage: { prefix: 'orpc:' }, + }) expect(encoded).toEqual( - await encodePeerMessage({ - kind: 'event-stream', - id, - json: { event: undefined, data: serializeData('hello world') }, - }, { prefix: 'orpc:' }), + await encodePeerMessage( + { + kind: 'event-stream', + id, + json: { event: undefined, data: serializeData('hello world') }, + }, + { prefix: 'orpc:' }, + ), ) expect(encoded).toSatisfy((encoded: string) => encoded.startsWith('orpc:')) }) diff --git a/packages/hibernation/src/encode-event.ts b/packages/hibernation/src/encode-event.ts index 7e51b03b99..d3c044a9b6 100644 --- a/packages/hibernation/src/encode-event.ts +++ b/packages/hibernation/src/encode-event.ts @@ -1,7 +1,7 @@ -import type { Public } from '@orpc/shared' -import type { EncodePeerMessageOptions, PeerEventStreamMessage } from '@standard-server/peer' import { RPCSerializer, toORPCError } from '@orpc/client' +import type { Public } from '@orpc/shared' import { unwrapEvent } from '@standard-server/core' +import type { EncodePeerMessageOptions, PeerEventStreamMessage } from '@standard-server/peer' import { encodePeerMessage } from '@standard-server/peer' export interface EncodeHibernationRPCEventOptions { @@ -36,7 +36,11 @@ export interface EncodeHibernationRPCEventOptions { export async function encodeHibernationRPCEvent( id: string, payload: unknown, - { event = 'message', serializer = new RPCSerializer(), encodePeerMessage: encodePeerMessageOptions }: EncodeHibernationRPCEventOptions = {}, + { + event = 'message', + serializer = new RPCSerializer(), + encodePeerMessage: encodePeerMessageOptions, + }: EncodeHibernationRPCEventOptions = {}, ): Promise> { const [originalData, meta] = unwrapEvent(payload) const data = event === 'error' ? toORPCError(originalData).toJSON() : originalData diff --git a/packages/hibernation/src/handler-plugin.test.ts b/packages/hibernation/src/handler-plugin.test.ts index c629485844..3e6cd9984d 100644 --- a/packages/hibernation/src/handler-plugin.test.ts +++ b/packages/hibernation/src/handler-plugin.test.ts @@ -2,10 +2,15 @@ import { oc } from '@orpc/contract' import { eventIterator, implement, os } from '@orpc/server' import { RPCHandlerCodec, StandardHandler } from '@orpc/server/standard' import { RPCHandler } from '@orpc/server/websocket' -import { decodePeerMessage, encodePeerMessage, HibernationAsyncIteratorClass } from '@standard-server/peer' +import { + decodePeerMessage, + encodePeerMessage, + HibernationAsyncIteratorClass, +} from '@standard-server/peer' import * as z from 'zod' -import { encodeHibernationRPCEvent } from './encode-event' -import { HibernationHandlerPlugin } from './handler-plugin' + +import { encodeHibernationRPCEvent } from './encode-event.ts' +import { HibernationHandlerPlugin } from './handler-plugin.ts' beforeEach(() => { vi.resetAllMocks() @@ -22,7 +27,9 @@ describe('hibernationHandlerPlugin', () => { const router = { ping: os.handler(() => new HibernationAsyncIteratorClass(callback)), pong: os.handler(() => 'pong'), - withOutputSchema: os.output(eventIterator(z.string())).handler(() => new HibernationAsyncIteratorClass(callback)), + withOutputSchema: os + .output(eventIterator(z.string())) + .handler(() => new HibernationAsyncIteratorClass(callback)), withOutputSchemaAndMiddleware: os .output(eventIterator(z.string())) .use(({ next }) => next()) @@ -37,27 +44,26 @@ describe('hibernationHandlerPlugin', () => { return { output: 'replaced', context: {} } }) .handler(() => new HibernationAsyncIteratorClass(callback)), - contract: implement(contract).ping.handler(() => new HibernationAsyncIteratorClass(callback)), + contract: implement(contract).ping.handler( + () => new HibernationAsyncIteratorClass(callback), + ), } const options = { - plugins: [ - new HibernationHandlerPlugin(), - ], - interceptors: [ - interceptor, - ], + plugins: [new HibernationHandlerPlugin()], + interceptors: [interceptor], } const handler = new StandardHandler(new RPCHandlerCodec(router), options) - const createRequest = (url: string) => ({ - url, - method: 'POST', - headers: {}, - signal: undefined, - resolveBody: () => Promise.resolve(undefined), - } as any) + const createRequest = (url: string) => + ({ + url, + method: 'POST', + headers: {}, + signal: undefined, + resolveBody: () => Promise.resolve(undefined), + }) as any it('sets body as HibernationAsyncIteratorClass if output is HibernationAsyncIteratorClass', async () => { const { response } = await handler.handle(createRequest('/ping'), { @@ -68,18 +74,17 @@ describe('hibernationHandlerPlugin', () => { expect(response!.body).toBeInstanceOf(HibernationAsyncIteratorClass) }) - it.each([ - '/withOutputSchema', - '/withOutputSchemaAndMiddleware', - '/contract', - ])('sets body as HibernationAsyncIteratorClass even when %s declares an event iterator output schema', async (url) => { - const { response } = await handler.handle(createRequest(url), { - context: {}, - }) + it.each(['/withOutputSchema', '/withOutputSchemaAndMiddleware', '/contract'])( + 'sets body as HibernationAsyncIteratorClass even when %s declares an event iterator output schema', + async (url) => { + const { response } = await handler.handle(createRequest(url), { + context: {}, + }) - expect(response!.status).toBe(200) - expect(response!.body).toBeInstanceOf(HibernationAsyncIteratorClass) - }) + expect(response!.status).toBe(200) + expect(response!.body).toBeInstanceOf(HibernationAsyncIteratorClass) + }, + ) it('does not hibernate a regular iterator with an output schema', async () => { const { response } = await handler.handle(createRequest('/iteratorWithOutputSchema'), { @@ -127,49 +132,48 @@ describe('hibernationHandlerPlugin', () => { }) describe('websocket integration', () => { - it.each([ - '/ping', - '/withOutputSchema', - '/contract', - ])('invokes the hibernation callback with the request id and can send events later (%s)', async (url) => { - const handler = new RPCHandler(router, options) - - const ws = { - addEventListener: vi.fn(), - send: vi.fn(() => undefined), - } - - const request = await encodePeerMessage({ - id: '19', - kind: 'request', - json: { - url, - method: 'POST', - headers: {}, - body: undefined, - }, - }) - - const result = await handler.message(ws as any, request) - - expect(result.matched).toBe(true) - expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenCalledWith('19') - - // only the response message is sent, events are sent manually later - expect(ws.send).toHaveBeenCalledTimes(1) - const decoded = decodePeerMessage((ws as any).send.mock.calls[0][0]) as any - expect(decoded.message.kind).toBe('response') - expect(decoded.message.json.headers).toEqual({ 'standard-server': 'event-stream' }) - - // events encoded with the request id belong to the same stream on the wire - const event = await encodeHibernationRPCEvent('19', 'hello') - const decodedEvent = decodePeerMessage(event as string) as any - expect(decodedEvent.message).toEqual({ - kind: 'event-stream', - id: '19', - json: { data: { json: 'hello' } }, - }) - }) + it.each(['/ping', '/withOutputSchema', '/contract'])( + 'invokes the hibernation callback with the request id and can send events later (%s)', + async (url) => { + const handler = new RPCHandler(router, options) + + const ws = { + addEventListener: vi.fn(), + send: vi.fn(() => undefined), + } + + const request = await encodePeerMessage({ + id: '19', + kind: 'request', + json: { + url, + method: 'POST', + headers: {}, + body: undefined, + }, + }) + + const result = await handler.message(ws as any, request) + + expect(result.matched).toBe(true) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith('19') + + // only the response message is sent, events are sent manually later + expect(ws.send).toHaveBeenCalledTimes(1) + const decoded = decodePeerMessage((ws as any).send.mock.calls[0][0]) as any + expect(decoded.message.kind).toBe('response') + expect(decoded.message.json.headers).toEqual({ 'standard-server': 'event-stream' }) + + // events encoded with the request id belong to the same stream on the wire + const event = await encodeHibernationRPCEvent('19', 'hello') + const decodedEvent = decodePeerMessage(event as string) as any + expect(decodedEvent.message).toEqual({ + kind: 'event-stream', + id: '19', + json: { data: { json: 'hello' } }, + }) + }, + ) }) }) diff --git a/packages/hibernation/src/handler-plugin.ts b/packages/hibernation/src/handler-plugin.ts index 72a4771e9a..bfd6ae3d4d 100644 --- a/packages/hibernation/src/handler-plugin.ts +++ b/packages/hibernation/src/handler-plugin.ts @@ -1,5 +1,9 @@ import type { Context, ErrorMap, ProcedureClientInterceptor, Schema } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '@orpc/server/standard' +import type { + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, +} from '@orpc/server/standard' import { toArray } from '@orpc/shared' import { HibernationAsyncIteratorClass } from '@standard-server/peer' @@ -51,11 +55,15 @@ export class HibernationHandlerPlugin implements StandardHand } } - const clientInterceptor: ProcedureClientInterceptor, ErrorMap> = async (options) => { + const clientInterceptor: ProcedureClientInterceptor, ErrorMap> = async ( + options, + ) => { const pluginContext = options.context[this.CONTEXT_SYMBOL] as PluginContext | undefined if (!pluginContext) { - throw new TypeError('[HibernationHandlerPlugin] Hibernation context has been corrupted or modified by another plugin or interceptor') + throw new TypeError( + '[HibernationHandlerPlugin] Hibernation context has been corrupted or modified by another plugin or interceptor', + ) } const output = await options.next() @@ -70,14 +78,8 @@ export class HibernationHandlerPlugin implements StandardHand return { ...options, - routingInterceptors: [ - routingInterceptor, - ...toArray(options.routingInterceptors), - ], - clientInterceptors: [ - ...toArray(options.clientInterceptors), - clientInterceptor, - ], + routingInterceptors: [routingInterceptor, ...toArray(options.routingInterceptors)], + clientInterceptors: [...toArray(options.clientInterceptors), clientInterceptor], } } } diff --git a/packages/hibernation/src/index.test.ts b/packages/hibernation/src/index.test.ts index 1906f0b858..16dc2bbbb3 100644 --- a/packages/hibernation/src/index.test.ts +++ b/packages/hibernation/src/index.test.ts @@ -1,5 +1,5 @@ it('exports encodeHibernationRPCEvent, HibernationHandlerPlugin', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ encodeHibernationRPCEvent: expect.any(Function), HibernationHandlerPlugin: expect.any(Function), }) diff --git a/packages/hibernation/src/index.ts b/packages/hibernation/src/index.ts index 9aee985fe8..9ebe084b5d 100644 --- a/packages/hibernation/src/index.ts +++ b/packages/hibernation/src/index.ts @@ -1,11 +1,11 @@ -export * from './encode-event' -export * from './handler-plugin' +export * from './encode-event.ts' +export * from './handler-plugin.ts' export { /** * @deprecated Use `HibernationHandlerPlugin` instead. */ HibernationHandlerPlugin as HibernationPlugin, -} from './handler-plugin' +} from './handler-plugin.ts' export { /** * An async iterator designed for the Hibernation APIs. Instead of streaming diff --git a/packages/hibernation/tsconfig.json b/packages/hibernation/tsconfig.json index 211b35f0cb..9979a0715c 100644 --- a/packages/hibernation/tsconfig.json +++ b/packages/hibernation/tsconfig.json @@ -1,17 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../server" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.bench.*", - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index 0b44a5e31f..a6a52ff8bb 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/json-schema/build.config.ts b/packages/json-schema/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/json-schema/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/json-schema/package.json b/packages/json-schema/package.json index cabdb3fb9c..ce3d905241 100644 --- a/packages/json-schema/package.json +++ b/packages/json-schema/package.json @@ -1,48 +1,47 @@ { "name": "@orpc/json-schema", - "type": "module", "version": "2.0.0-beta.48", "description": "JSON Schema conversion and smart request coercion for oRPC and OpenAPI", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "coercion", + "json-schema", + "openapi", + "orpc", + "typescript", + "validation" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/json-schema" }, - "keywords": [ - "orpc", - "json-schema", - "openapi", - "coercion", - "validation", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" + "prepack": "tsc" }, "dependencies": { "@openapi-spec/types": "~0.6.0", diff --git a/packages/json-schema/src/coercer.test.ts b/packages/json-schema/src/coercer.test.ts index dbecd311da..92fe2672a8 100644 --- a/packages/json-schema/src/coercer.test.ts +++ b/packages/json-schema/src/coercer.test.ts @@ -1,5 +1,5 @@ -import type { JsonSchema } from './types' -import { JsonSchemaCoercer } from './coercer' +import { JsonSchemaCoercer } from './coercer.ts' +import type { JsonSchema } from './types.ts' const coercer = new JsonSchemaCoercer() @@ -7,7 +7,11 @@ const coercer = new JsonSchemaCoercer() * Coerces like the smart coercion plugins do, from the `[jsonSchema, optional]` * pair a `JsonSchemaConverter` returns. */ -function coerce(schema: JsonSchema | Record, value: unknown, optional: boolean = false): unknown { +function coerce( + schema: JsonSchema | Record, + value: unknown, + optional: boolean = false, +): unknown { return coercer.coerce([schema as JsonSchema, optional], value) } @@ -113,20 +117,34 @@ describe('jsonSchemaCoercer', () => { expect(coerce(DATE_SCHEMA, '2020-01-01T06:15')).toEqual(new Date('2020-01-01T06:15')) expect(coerce(DATE_SCHEMA, '2020-01-01T06:15Z')).toEqual(new Date('2020-01-01T06:15Z')) expect(coerce(DATE_SCHEMA, '2020-01-01T06:15:00Z')).toEqual(new Date('2020-01-01T06:15:00Z')) - expect(coerce(DATE_SCHEMA, '2020-01-01T06:15:00.123Z')).toEqual(new Date('2020-01-01T06:15:00.123Z')) + expect(coerce(DATE_SCHEMA, '2020-01-01T06:15:00.123Z')).toEqual( + new Date('2020-01-01T06:15:00.123Z'), + ) expect(coerce(DATE_SCHEMA, '2020-01-01 06:15')).toEqual(new Date('2020-01-01 06:15')) // the expanded year form `toISOString` emits outside 0000-9999 - expect(coerce(DATE_SCHEMA, '+010000-01-01T00:00:00.000Z')).toEqual(new Date('+010000-01-01T00:00:00.000Z')) - expect(coerce(DATE_SCHEMA, '-000001-02-28T00:00:00.000Z')).toEqual(new Date('-000001-02-28T00:00:00.000Z')) + expect(coerce(DATE_SCHEMA, '+010000-01-01T00:00:00.000Z')).toEqual( + new Date('+010000-01-01T00:00:00.000Z'), + ) + expect(coerce(DATE_SCHEMA, '-000001-02-28T00:00:00.000Z')).toEqual( + new Date('-000001-02-28T00:00:00.000Z'), + ) expect(coerce(DATE_SCHEMA, '+000000-01-01')).toEqual(new Date('+000000-01-01')) }) it('coerces datetimes carrying a UTC offset in either direction', () => { - expect(coerce(DATE_SCHEMA, '2020-01-01T06:15:00+07:00')).toEqual(new Date('2020-01-01T06:15:00+07:00')) - expect(coerce(DATE_SCHEMA, '2020-01-01T06:15:00-07:00')).toEqual(new Date('2020-01-01T06:15:00-07:00')) - expect(coerce(DATE_SCHEMA, '2020-01-01T06:15:00-0700')).toEqual(new Date('2020-01-01T06:15:00-0700')) - expect(coerce(DATE_SCHEMA, '2020-01-01 06:15:00+07:00')).toEqual(new Date('2020-01-01T06:15:00+07:00')) + expect(coerce(DATE_SCHEMA, '2020-01-01T06:15:00+07:00')).toEqual( + new Date('2020-01-01T06:15:00+07:00'), + ) + expect(coerce(DATE_SCHEMA, '2020-01-01T06:15:00-07:00')).toEqual( + new Date('2020-01-01T06:15:00-07:00'), + ) + expect(coerce(DATE_SCHEMA, '2020-01-01T06:15:00-0700')).toEqual( + new Date('2020-01-01T06:15:00-0700'), + ) + expect(coerce(DATE_SCHEMA, '2020-01-01 06:15:00+07:00')).toEqual( + new Date('2020-01-01T06:15:00+07:00'), + ) }) it('leaves a date followed by anything other than a time and offset untouched', () => { @@ -178,7 +196,9 @@ describe('jsonSchemaCoercer', () => { expect(coerce(DATE_SCHEMA, '2020-02-29')).toEqual(new Date('2020-02-29')) expect(coerce(DATE_SCHEMA, '0048-02-29')).toEqual(new Date('0048-02-29')) // the offset moves the instant to March 1 in UTC, but the written day is real - expect(coerce(DATE_SCHEMA, '2020-02-29T23:00:00-07:00')).toEqual(new Date('2020-03-01T06:00:00Z')) + expect(coerce(DATE_SCHEMA, '2020-02-29T23:00:00-07:00')).toEqual( + new Date('2020-03-01T06:00:00Z'), + ) }) it('keeps a value that is already a Date', () => { @@ -212,7 +232,9 @@ describe('jsonSchemaCoercer', () => { it('leaves integer strings longer than maxBigIntDigits untouched', () => { expect(coerce(BIGINT_SCHEMA, '9'.repeat(4300))).toBe(BigInt('9'.repeat(4300))) expect(coerce(BIGINT_SCHEMA, '9'.repeat(4301))).toBe('9'.repeat(4301)) - expect(coerce({ anyOf: [BIGINT_SCHEMA, BIGINT_SCHEMA] }, '9'.repeat(4301))).toBe('9'.repeat(4301)) + expect(coerce({ anyOf: [BIGINT_SCHEMA, BIGINT_SCHEMA] }, '9'.repeat(4301))).toBe( + '9'.repeat(4301), + ) const limited = new JsonSchemaCoercer({ maxBigIntDigits: 3 }) expect(limited.coerce([BIGINT_SCHEMA as JsonSchema, false], '999')).toBe(999n) @@ -256,7 +278,17 @@ describe('jsonSchemaCoercer', () => { // z.map(z.number(), z.boolean()) const schema = mapSchema({ type: 'number' }, { type: 'boolean' }) - expect(coerce(schema, [['1', 'true'], ['2', 'off']])).toEqual(new Map([[1, true], [2, false]])) + expect( + coerce(schema, [ + ['1', 'true'], + ['2', 'off'], + ]), + ).toEqual( + new Map([ + [1, true], + [2, false], + ]), + ) expect(coerce(schema, [])).toEqual(new Map()) }) @@ -264,7 +296,15 @@ describe('jsonSchemaCoercer', () => { const schema = mapSchema({ type: 'number' }, { type: 'boolean' }) // duplicated key - expect(coerce(schema, [['1', 'true'], ['1', 'off']])).toEqual([[1, true], [1, false]]) + expect( + coerce(schema, [ + ['1', 'true'], + ['1', 'off'], + ]), + ).toEqual([ + [1, true], + [1, false], + ]) // not a pair expect(coerce(schema, [['1', 'true'], ['2']])).toEqual([[1, true], [2]]) expect(coerce(schema, ['1'])).toEqual(['1']) @@ -301,21 +341,46 @@ describe('jsonSchemaCoercer', () => { describe('tuples', () => { it('coerces tuples, with or without a rest schema', () => { // z.tuple([z.number(), z.boolean()]) - expect(coerce({ type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, ['1', 'on'])).toEqual([1, true]) + expect( + coerce({ type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, [ + '1', + 'on', + ]), + ).toEqual([1, true]) // z.tuple([z.number()], z.boolean()) - expect(coerce({ type: 'array', prefixItems: [{ type: 'number' }], items: { type: 'boolean' } }, ['1', 'on', 'off'])).toEqual([1, true, false]) + expect( + coerce({ type: 'array', prefixItems: [{ type: 'number' }], items: { type: 'boolean' } }, [ + '1', + 'on', + 'off', + ]), + ).toEqual([1, true, false]) // draft-07 tuple form - expect(coerce({ type: 'array', items: [{ type: 'number' }, { type: 'boolean' }], additionalItems: { type: 'number' } }, ['1', 'on', '2'])).toEqual([1, true, 2]) + expect( + coerce( + { + type: 'array', + items: [{ type: 'number' }, { type: 'boolean' }], + additionalItems: { type: 'number' }, + }, + ['1', 'on', '2'], + ), + ).toEqual([1, true, 2]) }) it('leaves items no schema describes untouched', () => { - expect(coerce({ type: 'array', prefixItems: [{ type: 'number' }] }, ['1', '2'])).toEqual([1, '2']) + expect(coerce({ type: 'array', prefixItems: [{ type: 'number' }] }, ['1', '2'])).toEqual([ + 1, + '2', + ]) expect(coerce({ type: 'array', items: [{ type: 'number' }] }, ['1', '2'])).toEqual([1, '2']) expect(coerce({ type: 'array' }, ['1'])).toEqual(['1']) }) it('coerces what it can when a tuple is too short', () => { - expect(coerce({ type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, ['1'])).toEqual([1]) + expect( + coerce({ type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, ['1']), + ).toEqual([1]) }) it('leaves non arrays untouched', () => { @@ -363,12 +428,17 @@ describe('jsonSchemaCoercer', () => { type: 'object', properties: { filters: { type: 'object', properties: { since: DATE_SCHEMA }, required: ['since'] }, - items: { type: 'array', items: { type: 'object', properties: { qty: { type: 'integer' } }, required: ['qty'] } }, + items: { + type: 'array', + items: { type: 'object', properties: { qty: { type: 'integer' } }, required: ['qty'] }, + }, }, required: ['filters', 'items'], } - expect(coerce(schema, { filters: { since: '2020-01-01' }, items: [{ qty: '1' }, { qty: '2' }] })).toEqual({ + expect( + coerce(schema, { filters: { since: '2020-01-01' }, items: [{ qty: '1' }, { qty: '2' }] }), + ).toEqual({ filters: { since: new Date('2020-01-01') }, items: [{ qty: 1 }, { qty: 2 }], }) @@ -376,8 +446,9 @@ describe('jsonSchemaCoercer', () => { it('coerces record values through additionalProperties and patternProperties', () => { // z.record(z.string(), z.number()) - expect(coerce({ type: 'object', additionalProperties: { type: 'number' } }, { a: '1', b: '2' })) - .toEqual({ a: 1, b: 2 }) + expect( + coerce({ type: 'object', additionalProperties: { type: 'number' } }, { a: '1', b: '2' }), + ).toEqual({ a: 1, b: 2 }) const schema = { type: 'object', @@ -386,7 +457,14 @@ describe('jsonSchemaCoercer', () => { additionalProperties: { type: 'number' }, } - expect(coerce(schema, { total: '3', atCreated: '2020-01-01', atUpdated: '2020-01-02', ratio: '0.5' })).toEqual({ + expect( + coerce(schema, { + total: '3', + atCreated: '2020-01-01', + atUpdated: '2020-01-02', + ratio: '0.5', + }), + ).toEqual({ total: 3, atCreated: new Date('2020-01-01'), atUpdated: new Date('2020-01-02'), @@ -396,26 +474,45 @@ describe('jsonSchemaCoercer', () => { it('leaves keys no schema describes untouched', () => { // z.strictObject({ page: z.int() }) emits additionalProperties: false - expect(coerce({ type: 'object', properties: { page: { type: 'integer' } }, additionalProperties: false }, { page: '2', extra: '3' })) - .toEqual({ page: 2, extra: '3' }) + expect( + coerce( + { + type: 'object', + properties: { page: { type: 'integer' } }, + additionalProperties: false, + }, + { page: '2', extra: '3' }, + ), + ).toEqual({ page: 2, extra: '3' }) - expect(coerce({ type: 'object', properties: { page: { type: 'integer' } } }, { page: '2', extra: '3' })) - .toEqual({ page: 2, extra: '3' }) + expect( + coerce( + { type: 'object', properties: { page: { type: 'integer' } } }, + { page: '2', extra: '3' }, + ), + ).toEqual({ page: 2, extra: '3' }) }) it('turns an array into an object with numeric keys, for bracket notation', () => { - expect(coerce({ type: 'object', properties: { 0: { type: 'number' }, 1: { type: 'boolean' } } }, ['123', 'true'])) - .toEqual({ 0: 123, 1: true }) + expect( + coerce({ type: 'object', properties: { 0: { type: 'number' }, 1: { type: 'boolean' } } }, [ + '123', + 'true', + ]), + ).toEqual({ 0: 123, 1: true }) }) it('keeps special keys such as __proto__ as own properties', () => { const value = JSON.parse('{"__proto__": {"nested": "1"}, "count": "2"}') - const coerced = coerce({ - type: 'object', - properties: { count: { type: 'integer' } }, - additionalProperties: { type: 'object', properties: { nested: { type: 'integer' } } }, - }, value) as Record + const coerced = coerce( + { + type: 'object', + properties: { count: { type: 'integer' } }, + additionalProperties: { type: 'object', properties: { nested: { type: 'integer' } } }, + }, + value, + ) as Record expect(Object.keys(coerced)).toEqual(['__proto__', 'count']) expect(coerced.count).toBe(2) @@ -425,7 +522,9 @@ describe('jsonSchemaCoercer', () => { }) it('leaves non objects untouched', () => { - expect(coerce({ type: 'object', properties: { page: { type: 'integer' } } }, 'nope')).toBe('nope') + expect(coerce({ type: 'object', properties: { page: { type: 'integer' } } }, 'nope')).toBe( + 'nope', + ) }) }) @@ -448,7 +547,9 @@ describe('jsonSchemaCoercer', () => { expect(coerce(schema, 'nope')).toBe('nope') // z.number().nullish() - expect(coerce({ anyOf: [{ type: 'number' }, { type: 'null' }] }, undefined, true)).toBeUndefined() + expect( + coerce({ anyOf: [{ type: 'number' }, { type: 'null' }] }, undefined, true), + ).toBeUndefined() }) it('coerces a number into a bigint inside a union', () => { @@ -465,7 +566,11 @@ describe('jsonSchemaCoercer', () => { // z.object({ at: z.date(), count: z.number() }).nullable() const schema = { anyOf: [ - { type: 'object', properties: { at: DATE_SCHEMA, count: { type: 'number' } }, required: ['at', 'count'] }, + { + type: 'object', + properties: { at: DATE_SCHEMA, count: { type: 'number' } }, + required: ['at', 'count'], + }, { type: 'null' }, ], } @@ -478,7 +583,13 @@ describe('jsonSchemaCoercer', () => { // `type: ['string', 'null']` expands into a `null` branch that keeps `x-native-type` const schema = { anyOf: [ - { type: 'object', properties: { at: { 'type': 'null', 'x-native-type': 'date' }, count: { type: 'string' } } }, + { + type: 'object', + properties: { + at: { 'type': 'null', 'x-native-type': 'date' }, + count: { type: 'string' }, + }, + }, { type: 'object', properties: { at: DATE_SCHEMA, count: { type: 'number' } } }, ], } @@ -493,7 +604,10 @@ describe('jsonSchemaCoercer', () => { anyOf: [ { type: 'object', - properties: { at: { 'type': ['string', 'null'], 'format': 'date-time', 'x-native-type': 'date' }, count: { type: 'number' } }, + properties: { + at: { 'type': ['string', 'null'], 'format': 'date-time', 'x-native-type': 'date' }, + count: { type: 'number' }, + }, required: ['at', 'count'], }, { type: 'null' }, @@ -502,7 +616,10 @@ describe('jsonSchemaCoercer', () => { const at = new Date('2020-01-01T00:00:00.000Z') expect(coerce(schema, { at: null, count: '1' })).toEqual({ at: null, count: 1 }) - expect(coerce(schema, { at: '2020-01-01T00:00:00.000Z', count: '1' })).toEqual({ at, count: 1 }) + expect(coerce(schema, { at: '2020-01-01T00:00:00.000Z', count: '1' })).toEqual({ + at, + count: 1, + }) expect(coerce(schema, { at, count: '1' })).toEqual({ at, count: 1 }) }) @@ -510,7 +627,13 @@ describe('jsonSchemaCoercer', () => { // z.tuple([z.number(), z.number().optional()]).nullable() const schema = { anyOf: [ - { type: 'array', prefixItems: [{ type: 'number' }, { type: 'number' }], items: false, minItems: 1, maxItems: 2 }, + { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'number' }], + items: false, + minItems: 1, + maxItems: 2, + }, { type: 'null' }, ], } @@ -533,7 +656,11 @@ describe('jsonSchemaCoercer', () => { // z.union([z.object({ mode: z.string(), count: z.int() }), z.object({ count: z.number() })]) const schema = { anyOf: [ - { type: 'object', properties: { mode: { type: 'string' }, count: { type: 'integer' } }, required: ['mode', 'count'] }, + { + type: 'object', + properties: { mode: { type: 'string' }, count: { type: 'integer' } }, + required: ['mode', 'count'], + }, { type: 'object', properties: { count: { type: 'number' } }, required: ['count'] }, ], } @@ -545,7 +672,11 @@ describe('jsonSchemaCoercer', () => { it('picks the tuple branch that describes every item', () => { const schema = { anyOf: [ - { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }, { type: 'boolean' }], items: { type: 'number' } }, + { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'boolean' }, { type: 'boolean' }], + items: { type: 'number' }, + }, { type: 'array', prefixItems: [{ type: 'number' }], items: { type: 'number' } }, ], } @@ -565,18 +696,33 @@ describe('jsonSchemaCoercer', () => { }, { type: 'object', - properties: { type: { type: 'string', const: 'image' }, width: { type: 'integer' }, lossless: { type: 'boolean' } }, + properties: { + type: { type: 'string', const: 'image' }, + width: { type: 'integer' }, + lossless: { type: 'boolean' }, + }, required: ['type', 'width'], }, ], } expect(coerce(schema, { type: 'text', length: '12' })).toEqual({ type: 'text', length: 12 }) - expect(coerce(schema, { type: 'image', width: '800', lossless: 'off' })).toEqual({ type: 'image', width: 800, lossless: false }) - expect(coerce(schema, { type: 'video', width: '800' })).toEqual({ type: 'video', width: '800' }) + expect(coerce(schema, { type: 'image', width: '800', lossless: 'off' })).toEqual({ + type: 'image', + width: 800, + lossless: false, + }) + expect(coerce(schema, { type: 'video', width: '800' })).toEqual({ + type: 'video', + width: '800', + }) // extra keys do not disqualify the discriminator branch, validators usually strip them - expect(coerce(schema, { type: 'text', length: '12', tracking: 'xyz' })).toEqual({ type: 'text', length: 12, tracking: 'xyz' }) + expect(coerce(schema, { type: 'text', length: '12', tracking: 'xyz' })).toEqual({ + type: 'text', + length: 12, + tracking: 'xyz', + }) }) it('prefers the branch that describes every key', () => { @@ -613,17 +759,16 @@ describe('jsonSchemaCoercer', () => { }) it('leaves the value untouched when no branch matches', () => { - expect(coerce({ anyOf: [{ type: 'number' }, { type: 'boolean' }] }, 'invalid')).toBe('invalid') + expect(coerce({ anyOf: [{ type: 'number' }, { type: 'boolean' }] }, 'invalid')).toBe( + 'invalid', + ) }) it('does not let a matching branch mask a keyword that already failed', () => { // `type` and `anyOf` both apply to the same value, and a string can never satisfy `type: 'null'`, // so the first branch must lose to the one that can really accept the value const schema = { - anyOf: [ - { type: 'null', anyOf: [{ type: 'string' }] }, - { type: 'boolean' }, - ], + anyOf: [{ type: 'null', anyOf: [{ type: 'string' }] }, { type: 'boolean' }], } expect(coerce(schema, 'true')).toBe(true) @@ -665,7 +810,11 @@ describe('jsonSchemaCoercer', () => { const schema = { $defs: { Page: { $ref: '#/$defs/PageBase' }, - PageBase: { type: 'object', properties: { limit: { type: 'integer' } }, required: ['limit'] }, + PageBase: { + type: 'object', + properties: { limit: { type: 'integer' } }, + required: ['limit'], + }, }, $ref: '#/$defs/Page', } @@ -694,14 +843,16 @@ describe('jsonSchemaCoercer', () => { required: ['name', 'children'], } - expect(coerce(schema, { - name: 'root', - visits: '1', - children: [ - { name: 'a', visits: '2', children: [] }, - { name: 'b', visits: '3', children: [{ name: 'c', visits: '4', children: [] }] }, - ], - })).toEqual({ + expect( + coerce(schema, { + name: 'root', + visits: '1', + children: [ + { name: 'a', visits: '2', children: [] }, + { name: 'b', visits: '3', children: [{ name: 'c', visits: '4', children: [] }] }, + ], + }), + ).toEqual({ name: 'root', visits: 1, children: [ @@ -725,8 +876,9 @@ describe('jsonSchemaCoercer', () => { }, } - expect(coerce(schema, { visits: '1', next: { visits: '2', next: { visits: '3' } } })) - .toEqual({ visits: 1, next: { visits: 2, next: { visits: 3 } } }) + expect(coerce(schema, { visits: '1', next: { visits: '2', next: { visits: '3' } } })).toEqual( + { visits: 1, next: { visits: 2, next: { visits: 3 } } }, + ) }) }) @@ -745,15 +897,32 @@ describe('jsonSchemaCoercer', () => { it('stops instead of looping on a $ref cycle', () => { expect(coerce({ $ref: '#' }, { a: '1' })).toEqual({ a: '1' }) - expect(coerce({ - $defs: { Loop: { allOf: [{ $ref: '#/$defs/Loop' }, { type: 'object', properties: { a: { type: 'integer' } } }] } }, - $ref: '#/$defs/Loop', - }, { a: '1' })).toEqual({ a: 1 }) + expect( + coerce( + { + $defs: { + Loop: { + allOf: [ + { $ref: '#/$defs/Loop' }, + { type: 'object', properties: { a: { type: 'integer' } } }, + ], + }, + }, + $ref: '#/$defs/Loop', + }, + { a: '1' }, + ), + ).toEqual({ a: 1 }) - expect(coerce({ - $defs: { A: { $ref: '#/$defs/B' }, B: { $ref: '#/$defs/A' } }, - $ref: '#/$defs/A', - }, { a: '1' })).toEqual({ a: '1' }) + expect( + coerce( + { + $defs: { A: { $ref: '#/$defs/B' }, B: { $ref: '#/$defs/A' } }, + $ref: '#/$defs/A', + }, + { a: '1' }, + ), + ).toEqual({ a: '1' }) }) it('ignores a type it does not know', () => { @@ -763,7 +932,9 @@ describe('jsonSchemaCoercer', () => { }) it('ignores x-native-type values it does not know', () => { - expect(coerce({ 'type': 'string', 'x-native-type': 'temporal' }, '2020-01-01')).toBe('2020-01-01') + expect(coerce({ 'type': 'string', 'x-native-type': 'temporal' }, '2020-01-01')).toBe( + '2020-01-01', + ) expect(coerce({ 'type': 'number', 'x-native-type': 42 }, '1')).toBe(1) }) @@ -789,23 +960,35 @@ describe('jsonSchemaCoercer', () => { patternProperties: { '(unclosed': { type: 'number' }, '^n': { type: 'number' } }, } - expect(coerce(schema, { total: '3', n1: '1', other: 'x' })).toEqual({ total: 3, n1: 1, other: 'x' }) + expect(coerce(schema, { total: '3', n1: '1', other: 'x' })).toEqual({ + total: 3, + n1: 1, + other: 'x', + }) }) it('accepts boolean schemas for properties and items', () => { const value = { free: ['anything', 1], never: 'kept' } - expect(coerce({ - type: 'object', - properties: { free: { type: 'array', items: true }, never: false }, - }, value)).toBe(value) + expect( + coerce( + { + type: 'object', + properties: { free: { type: 'array', items: true }, never: false }, + }, + value, + ), + ).toBe(value) const record = { a: 'kept' } expect(coerce({ type: 'object', additionalProperties: true }, record)).toBe(record) }) it('tolerates a tuple whose prefixItems is a single schema', () => { - expect(coerce({ type: 'array', prefixItems: { type: 'number' } }, ['1', '2'])).toEqual([1, '2']) + expect(coerce({ type: 'array', prefixItems: { type: 'number' } }, ['1', '2'])).toEqual([ + 1, + '2', + ]) }) it('tolerates an empty draft-07 tuple form', () => { @@ -815,7 +998,9 @@ describe('jsonSchemaCoercer', () => { describe('untouched values', () => { it('skips a missing optional input entirely', () => { - expect(coerce({ type: 'object', properties: { page: { type: 'integer' } } }, undefined, true)).toBeUndefined() + expect( + coerce({ type: 'object', properties: { page: { type: 'integer' } } }, undefined, true), + ).toBeUndefined() expect(coerce({ type: 'integer' }, undefined, true)).toBeUndefined() // a required input still reaches validation untouched @@ -865,45 +1050,83 @@ describe('jsonSchemaCoercer', () => { required: ['page', 'doc'], $defs: { node: { - oneOf: kinds.map(kind => ({ + oneOf: kinds.map((kind) => ({ type: 'object', - properties: { type: { type: 'string', const: kind }, children: { type: 'array', items: { $ref: '#/$defs/node' } } }, + properties: { + type: { type: 'string', const: kind }, + children: { type: 'array', items: { $ref: '#/$defs/node' } }, + }, required: ['type', 'children'], })), }, }, } - const doc = nested(depth, child => ({ type, children: [child] }), { type, children: [] }) + const doc = nested(depth, (child) => ({ type, children: [child] }), { type, children: [] }) expect(linear.coerce([schema, false], { page: '1', doc })).toEqual({ page: 1, doc }) }) it('coerces a record branch listed before an array branch', () => { - const schema: JsonSchema = { anyOf: [{ type: 'integer' }, { type: 'object', additionalProperties: { $ref: '#' } }, { type: 'array', items: { $ref: '#' } }] } + const schema: JsonSchema = { + anyOf: [ + { type: 'integer' }, + { type: 'object', additionalProperties: { $ref: '#' } }, + { type: 'array', items: { $ref: '#' } }, + ], + } // the record branch is tried first, so it turns every array into an object - expect(linear.coerce([schema, false], nested(14, inner => [inner], '1'))).toEqual(nested(14, inner => ({ 0: inner }), 1)) + expect( + linear.coerce( + [schema, false], + nested(14, (inner) => [inner], '1'), + ), + ).toEqual(nested(14, (inner) => ({ 0: inner }), 1)) }) - const BASE: JsonSchema = { type: 'object', properties: { n: { type: 'integer' }, child: { $ref: '#' } } } + const BASE: JsonSchema = { + type: 'object', + properties: { n: { type: 'integer' }, child: { $ref: '#' } }, + } const REACH_CHILD: JsonSchema = { type: 'object', properties: { child: { $ref: '#' } } } it.each<[string, JsonSchema]>([ ['$ref', { $defs: { base: BASE }, $ref: '#/$defs/base', ...REACH_CHILD }], ['allOf', { ...BASE, allOf: [REACH_CHILD] }], ['not', { ...BASE, not: REACH_CHILD }], - ])('coerces a schema whose %s reaches the same nested values as its properties', (_, schema) => { - expect(linear.coerce([schema, false], nested(14, child => ({ n: '1', child }), { n: '1' }))) - .toEqual(nested(14, child => ({ n: 1, child }), { n: 1 })) - }) + ])( + 'coerces a schema whose %s reaches the same nested values as its properties', + (_, schema) => { + expect( + linear.coerce( + [schema, false], + nested(14, (child) => ({ n: '1', child }), { n: '1' }), + ), + ).toEqual(nested(14, (child) => ({ n: 1, child }), { n: 1 })) + }, + ) it('coerces every level of a deep recursive union', () => { // z.lazy(() => z.union([z.object({ n: z.number(), children: z.array(Node) }), z.object({ s: z.string(), children: z.array(Node) })])) const schema: JsonSchema = { anyOf: [ - { type: 'object', properties: { n: { type: 'number' }, children: { type: 'array', items: { $ref: '#' } } }, required: ['n', 'children'] }, - { type: 'object', properties: { s: { type: 'string' }, children: { type: 'array', items: { $ref: '#' } } }, required: ['s', 'children'] }, + { + type: 'object', + properties: { + n: { type: 'number' }, + children: { type: 'array', items: { $ref: '#' } }, + }, + required: ['n', 'children'], + }, + { + type: 'object', + properties: { + s: { type: 'string' }, + children: { type: 'array', items: { $ref: '#' } }, + }, + required: ['s', 'children'], + }, ], } @@ -919,7 +1142,11 @@ describe('jsonSchemaCoercer', () => { }) it('keeps its state when the input calls back into the same coercer', () => { - const schema = { $defs: { int: { type: 'integer' } }, type: 'object', properties: { a: { $ref: '#/$defs/int' }, b: { $ref: '#/$defs/int' } } } + const schema = { + $defs: { int: { type: 'integer' } }, + type: 'object', + properties: { a: { $ref: '#/$defs/int' }, b: { $ref: '#/$defs/int' } }, + } const value = { get a(): unknown { return coerce({ type: 'integer' }, '1') @@ -933,16 +1160,23 @@ describe('jsonSchemaCoercer', () => { describe('step budget', () => { // each level tries both branches on the same string, so the work doubles per level const EXPONENTIAL_SCHEMA: JsonSchema = { - $defs: Object.fromEntries(Array.from({ length: 19 }, (_, i) => [ - `d${i}`, - i === 18 ? { type: 'integer' } : { anyOf: [{ $ref: `#/$defs/d${i + 1}` }, { $ref: `#/$defs/d${i + 1}` }] }, - ])), + $defs: Object.fromEntries( + Array.from({ length: 19 }, (_, i) => [ + `d${i}`, + i === 18 + ? { type: 'integer' } + : { anyOf: [{ $ref: `#/$defs/d${i + 1}` }, { $ref: `#/$defs/d${i + 1}` }] }, + ]), + ), type: 'object', properties: { page: { type: 'integer' }, count: { $ref: '#/$defs/d0' } }, } // four steps per item - const UNION_ITEMS: JsonSchema = { type: 'array', items: { anyOf: [{ type: 'boolean' }, { type: 'null' }, { type: 'integer' }] } } + const UNION_ITEMS: JsonSchema = { + type: 'array', + items: { anyOf: [{ type: 'boolean' }, { type: 'null' }, { type: 'integer' }] }, + } const twoStepsPerValue = new JsonSchemaCoercer({ maxStepsPerValue: 2 }) @@ -953,18 +1187,30 @@ describe('jsonSchemaCoercer', () => { expect(value).toEqual({ page: '2', count: '1' }) // the coercer keeps no state from a coercion it gave up on - expect(twoStepsPerValue.coerce([{ type: 'object', properties: { page: { type: 'integer' } } }, false], { page: '2' })).toEqual({ page: 2 }) + expect( + twoStepsPerValue.coerce( + [{ type: 'object', properties: { page: { type: 'integer' } } }, false], + { page: '2' }, + ), + ).toEqual({ page: 2 }) }) it('budgets inputs with fewer than 100 values as 100 values', () => { - expect(new JsonSchemaCoercer({ maxStepsPerValue: 1 }).coerce([UNION_ITEMS.items as JsonSchema, false], '1')).toBe(1) + expect( + new JsonSchemaCoercer({ maxStepsPerValue: 1 }).coerce( + [UNION_ITEMS.items as JsonSchema, false], + '1', + ), + ).toBe(1) }) it('extends the budget with the number of values in the input', () => { const strings = Array.from({ length: 1_000 }, (_, i) => String(i)) expect(twoStepsPerValue.coerce([UNION_ITEMS, false], strings)).toBe(strings) - expect(new JsonSchemaCoercer({ maxStepsPerValue: 5 }).coerce([UNION_ITEMS, false], strings)).toEqual(strings.map(Number)) + expect( + new JsonSchemaCoercer({ maxStepsPerValue: 5 }).coerce([UNION_ITEMS, false], strings), + ).toEqual(strings.map(Number)) }) it('stops counting a wide array once the budget covers the steps taken', () => { @@ -980,18 +1226,30 @@ describe('jsonSchemaCoercer', () => { }) // runs past the minimum budget of 1_000 steps, so the input gets counted - expect(new JsonSchemaCoercer({ maxStepsPerValue: 10 }).coerce([{ type: 'array', items: { type: 'integer' } }, false], items)) - .toEqual(Array.from({ length: 1_500 }).fill(1)) + expect( + new JsonSchemaCoercer({ maxStepsPerValue: 10 }).coerce( + [{ type: 'array', items: { type: 'integer' } }, false], + items, + ), + ).toEqual(Array.from({ length: 1_500 }).fill(1)) // coercion reads each item once, and counting only a few hundred more expect(reads).toBeLessThan(2_000) }) it('counts circular references and non-plain objects as one value each', () => { - const value: Record = { strings: Array.from({ length: 300 }, (_, i) => String(i)), bytes: new Uint8Array(1_000) } + const value: Record = { + strings: Array.from({ length: 300 }, (_, i) => String(i)), + bytes: new Uint8Array(1_000), + } value.self = value // counting `self` again or every byte would lift the budget enough to finish - expect(twoStepsPerValue.coerce([{ type: 'object', properties: { strings: UNION_ITEMS } }, false], value)).toBe(value) + expect( + twoStepsPerValue.coerce( + [{ type: 'object', properties: { strings: UNION_ITEMS } }, false], + value, + ), + ).toBe(value) }) it('still throws errors other than running out of steps', () => { @@ -1001,7 +1259,9 @@ describe('jsonSchemaCoercer', () => { }, } - expect(() => coerce({ type: 'object', properties: { page: { type: 'integer' } } }, value)).toThrow('getter failed') + expect(() => + coerce({ type: 'object', properties: { page: { type: 'integer' } } }, value), + ).toThrow('getter failed') }) }) }) diff --git a/packages/json-schema/src/coercer.ts b/packages/json-schema/src/coercer.ts index d35857c15c..44e6eb33de 100644 --- a/packages/json-schema/src/coercer.ts +++ b/packages/json-schema/src/coercer.ts @@ -1,7 +1,16 @@ -import type { JsonSchema } from './types' -import { DEFAULT_MAX_BIGINT_DIGITS, getOwn, isPlainObject, isTypescriptObject, parseBigIntString, toArray, tryOrUndefined } from '@orpc/shared' -import { resolveJsonSchemaLocalPointer } from './ref-utils' -import { JsonSchemaXNativeType } from './types' +import { + DEFAULT_MAX_BIGINT_DIGITS, + getOwn, + isPlainObject, + isTypescriptObject, + parseBigIntString, + toArray, + tryOrUndefined, +} from '@orpc/shared' + +import { resolveJsonSchemaLocalPointer } from './ref-utils.ts' +import type { JsonSchema } from './types.ts' +import { JsonSchemaXNativeType } from './types.ts' /** * How well a value matches a schema, used to pick the best union branch. @@ -32,37 +41,56 @@ interface NativeType { } // IIFE so bundlers can drop the enum reads in the entries too, not just the `new Map` call -const NATIVE_TYPES = /* @__PURE__ */ (() => new Map([ - [JsonSchemaXNativeType.Date, { - types: ['string'], - is: value => value instanceof Date, - coerce: value => typeof value === 'string' ? stringToDate(value) : value, - }], - [JsonSchemaXNativeType.BigInt, { - types: ['string', 'integer', 'number'], - is: value => typeof value === 'bigint', - coerce: (value, { maxBigIntDigits }) => typeof value === 'string' - ? stringToBigInt(value, maxBigIntDigits) - : typeof value === 'number' ? numberToBigInt(value) : value, - }], - [JsonSchemaXNativeType.Url, { - types: ['string'], - is: value => value instanceof URL, - coerce: value => typeof value === 'string' ? stringToURL(value) : value, - }], - [JsonSchemaXNativeType.Set, { - types: ['array'], - is: value => value instanceof Set, - coerce: value => Array.isArray(value) ? arrayToSet(value) : value, - afterTypeCheck: true, - }], - [JsonSchemaXNativeType.Map, { - types: ['array'], - is: value => value instanceof Map, - coerce: value => Array.isArray(value) ? arrayToMap(value) : value, - afterTypeCheck: true, - }], -]))() +const NATIVE_TYPES = /* @__PURE__ */ (() => + new Map([ + [ + JsonSchemaXNativeType.Date, + { + types: ['string'], + is: (value) => value instanceof Date, + coerce: (value) => (typeof value === 'string' ? stringToDate(value) : value), + }, + ], + [ + JsonSchemaXNativeType.BigInt, + { + types: ['string', 'integer', 'number'], + is: (value) => typeof value === 'bigint', + coerce: (value, { maxBigIntDigits }) => + typeof value === 'string' + ? stringToBigInt(value, maxBigIntDigits) + : typeof value === 'number' + ? numberToBigInt(value) + : value, + }, + ], + [ + JsonSchemaXNativeType.Url, + { + types: ['string'], + is: (value) => value instanceof URL, + coerce: (value) => (typeof value === 'string' ? stringToURL(value) : value), + }, + ], + [ + JsonSchemaXNativeType.Set, + { + types: ['array'], + is: (value) => value instanceof Set, + coerce: (value) => (Array.isArray(value) ? arrayToSet(value) : value), + afterTypeCheck: true, + }, + ], + [ + JsonSchemaXNativeType.Map, + { + types: ['array'], + is: (value) => value instanceof Map, + coerce: (value) => (Array.isArray(value) ? arrayToMap(value) : value), + afterTypeCheck: true, + }, + ], + ]))() function minSatisfaction(a: Satisfaction, b: Satisfaction): Satisfaction { return a < b ? a : b @@ -100,7 +128,14 @@ interface CoercionContext { } function createContext(): CoercionContext { - return { rootSchema: true, memo: undefined, memoize: false, steps: 0, maxSteps: 0, input: undefined } + return { + rootSchema: true, + memo: undefined, + memoize: false, + steps: 0, + maxSteps: 0, + input: undefined, + } } class CoercionBudgetExceededError extends Error {} @@ -136,16 +171,14 @@ export class JsonSchemaCoercer { try { const [, coerced] = this.coerceInternal(context, schema, value) return coerced - } - catch (error) { + } catch (error) { // partially coerced output would be harder to predict than the original input if (error instanceof CoercionBudgetExceededError) { return value } throw error - } - finally { + } finally { // keep no reference to this call's input or results context.memo = undefined context.input = undefined @@ -174,7 +207,7 @@ export class JsonSchemaCoercer { if (Array.isArray(schema.type)) { return this.coerceInternal( context, - { anyOf: schema.type.map(type => ({ ...schema, type })) }, + { anyOf: schema.type.map((type) => ({ ...schema, type })) }, value, appliedRefs, ) @@ -205,27 +238,25 @@ export class JsonSchemaCoercer { if (enumValues.includes(numberValue)) { coerced = numberValue - } - else { + } else { const booleanValue = stringToBoolean(coerced) if (enumValues.includes(booleanValue)) { coerced = booleanValue - } - else { + } else { satisfied = UNSATISFIED } } - } - else { + } else { satisfied = UNSATISFIED } } const declaredNativeType = NATIVE_TYPES.get(schema['x-native-type']) - const nativeType = schema.type === undefined || declaredNativeType?.types.includes(schema.type) - ? declaredNativeType - : undefined + const nativeType = + schema.type === undefined || declaredNativeType?.types.includes(schema.type) + ? declaredNativeType + : undefined if (nativeType && !nativeType.afterTypeCheck) { coerced = nativeType.coerce(coerced, this.nativeCoerceOptions) @@ -282,15 +313,16 @@ export class JsonSchemaCoercer { } case 'array': { if (Array.isArray(coerced)) { - const prefixItemSchemas: readonly JsonSchema[] = 'prefixItems' in schema - ? toArray(schema.prefixItems) - : Array.isArray(schema.items) - ? schema.items - : [] + const prefixItemSchemas: readonly JsonSchema[] = + 'prefixItems' in schema + ? toArray(schema.prefixItems) + : Array.isArray(schema.items) + ? schema.items + : [] const itemSchema: JsonSchema | undefined = Array.isArray(schema.items) - ? schema.additionalItems as JsonSchema | undefined - : schema.items as JsonSchema | undefined + ? (schema.additionalItems as JsonSchema | undefined) + : (schema.items as JsonSchema | undefined) let shouldUseCoercedItems = false @@ -319,14 +351,14 @@ export class JsonSchemaCoercer { if (shouldUseCoercedItems) { coerced = coercedItems } - } - else { + } else { satisfied = UNSATISFIED } break } case 'object': { if (Array.isArray(coerced)) { + // oxlint-disable-next-line typescript/no-misused-spread coerced = { ...coerced } } @@ -335,27 +367,28 @@ export class JsonSchemaCoercer { // copy here so special keys like `__proto__` are kept as own properties const coercedItems = { ...coerced } - const patternProperties = Object.entries(schema.patternProperties ?? {}) - .flatMap(([key, value]) => { + const patternProperties = Object.entries(schema.patternProperties ?? {}).flatMap( + ([key, value]) => { // an invalid pattern is a schema mistake, it should not break coercion of the other keys const pattern = tryOrUndefined(() => new RegExp(key)) return pattern ? [[pattern, value] as const] : [] - }) + }, + ) const propertySchemas: Record = schema.properties ?? {} for (const key of Object.keys(coerced)) { const value = coerced[key] - const subSchema = getOwn(propertySchemas, key) - ?? patternProperties.find(([pattern]) => pattern.test(key))?.[1] - ?? schema.additionalProperties + const subSchema = + getOwn(propertySchemas, key) ?? + patternProperties.find(([pattern]) => pattern.test(key))?.[1] ?? + schema.additionalProperties if (value !== undefined || schema.required?.includes(key)) { if (subSchema === undefined) { satisfied = minSatisfaction(satisfied, LOOSELY_SATISFIED) - } - else { + } else { const [subSatisfied, subCoerced] = this.coerceInternal(context, subSchema, value) coercedItems[key] = subCoerced @@ -368,15 +401,14 @@ export class JsonSchemaCoercer { } } - if (schema.required?.some(key => !Object.hasOwn(coercedItems, key))) { + if (schema.required?.some((key) => !Object.hasOwn(coercedItems, key))) { satisfied = UNSATISFIED } if (shouldUseCoercedItems) { coerced = coercedItems } - } - else { + } else { satisfied = UNSATISFIED } @@ -399,7 +431,12 @@ export class JsonSchemaCoercer { context.memoize = true for (const subSchema of schema.allOf) { - const [subSatisfied, subCoerced] = this.coerceInternal(context, subSchema, coerced, appliedRefs) + const [subSatisfied, subCoerced] = this.coerceInternal( + context, + subSchema, + coerced, + appliedRefs, + ) coerced = subCoerced satisfied = minSatisfaction(satisfied, subSatisfied) @@ -414,10 +451,15 @@ export class JsonSchemaCoercer { * The best branch is the first one that accepts the value untouched, * then the most satisfied branch that accepts it after a conversion. */ - let best: { satisfied: Satisfaction, value: unknown } | undefined + let best: { satisfied: Satisfaction; value: unknown } | undefined for (const subSchema of schema[key]) { - const [subSatisfied, subCoerced] = this.coerceInternal(context, subSchema, coerced, appliedRefs) + const [subSatisfied, subCoerced] = this.coerceInternal( + context, + subSchema, + coerced, + appliedRefs, + ) if (subSatisfied === UNSATISFIED) { continue @@ -437,8 +479,7 @@ export class JsonSchemaCoercer { if (best) { coerced = best.value satisfied = minSatisfaction(satisfied, best.satisfied) - } - else { + } else { satisfied = UNSATISFIED } } @@ -494,8 +535,9 @@ export class JsonSchemaCoercer { } private extendBudget(context: CoercionContext): void { - const limit = 2 * context.steps / this.maxStepsPerValue - context.maxSteps = this.maxStepsPerValue * Math.max(countValues(context.input, limit), MIN_BUDGETED_VALUES) + const limit = (2 * context.steps) / this.maxStepsPerValue + context.maxSteps = + this.maxStepsPerValue * Math.max(countValues(context.input, limit), MIN_BUDGETED_VALUES) if (context.steps > context.maxSteps) { throw new CoercionBudgetExceededError() @@ -590,7 +632,8 @@ function stringToBoolean(value: string): boolean | string { return value } -const DATE_TIME_PATTERN = /^(?!-0{6})(?:[+-]\d{2})?\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/ +const DATE_TIME_PATTERN = + /^(?!-0{6})(?:[+-]\d{2})?\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/ const DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] function stringToDate(value: string): Date | string { if (!DATE_TIME_PATTERN.test(value)) { @@ -602,9 +645,9 @@ function stringToDate(value: string): Date | string { const day = readTwoDigits(value, yearEnd + 4) if ( - day < 1 - || day > (DAYS_IN_MONTH[month - 1] ?? 0) - || (month === 2 && day === 29 && !isLeapYear(Number(value.slice(0, yearEnd)))) + day < 1 || + day > (DAYS_IN_MONTH[month - 1] ?? 0) || + (month === 2 && day === 29 && !isLeapYear(Number(value.slice(0, yearEnd)))) ) { return value } @@ -641,7 +684,7 @@ function arrayToSet(value: unknown[]): Set | unknown[] { } function arrayToMap(value: unknown[]): Map | unknown[] { - if (value.some(item => !Array.isArray(item) || item.length !== 2)) { + if (value.some((item) => !Array.isArray(item) || item.length !== 2)) { return value } diff --git a/packages/json-schema/src/composition-utils.test.ts b/packages/json-schema/src/composition-utils.test.ts index 37e68e22cc..ffa5ef2977 100644 --- a/packages/json-schema/src/composition-utils.test.ts +++ b/packages/json-schema/src/composition-utils.test.ts @@ -1,5 +1,3 @@ -import type { JsonSchema } from './types' -import type { JsonObjectSchema } from './utils' import { combineJsonObjectSchemaEntries, combineJsonSchemasWithComposition, @@ -9,7 +7,9 @@ import { flattenJsonUnionSchema, isJsonObjectLikeSchema, matchArrayableJsonSchema, -} from './composition-utils' +} from './composition-utils.ts' +import type { JsonSchema } from './types.ts' +import type { JsonObjectSchema } from './utils.ts' describe('extractJsonObjectSchemaEntries', () => { it('returns undefined for non object-able schemas', () => { @@ -20,18 +20,20 @@ describe('extractJsonObjectSchemaEntries', () => { 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' }, + expect( + extractJsonObjectSchemaEntries({ + $ref: '#/$defs/Missing', + $defs: { + Present: { + type: 'object', + properties: { + value: { type: 'string' }, + }, }, }, - }, - })).toBeUndefined() - expect(extractJsonObjectSchemaEntries({ })).toBeUndefined() + }), + ).toBeUndefined() + expect(extractJsonObjectSchemaEntries({})).toBeUndefined() }) it('return empty array for empty object like schemas', () => { @@ -61,20 +63,9 @@ describe('extractJsonObjectSchemaEntries', () => { }) 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', + expect( + extractJsonObjectSchemaEntries({ + $ref: '#/$defs/Node', $defs: { Node: { type: 'object', @@ -84,7 +75,24 @@ describe('extractJsonObjectSchemaEntries', () => { required: ['value'], }, }, - }, false], + }), + ).toEqual([ + [ + 'value', + { + type: 'string', + $defs: { + Node: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + }, + }, + false, + ], ]) }) @@ -126,145 +134,152 @@ describe('extractJsonObjectSchemaEntries', () => { 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', + [ + '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' }], }, - anyOf: [{ type: 'number' }, { type: 'boolean' }], - }, true], + 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' }, + expect( + extractJsonObjectSchemaEntries({ + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + branch: { type: 'string' }, + }, }, - }, - ], - })).toEqual([ - ['branch', { type: 'string' }, true], - ]) + ], + }), + ).toEqual([['branch', { type: 'string' }, true]]) }) it('wraps pure union property merges in anyOf', () => { - expect(extractJsonObjectSchemaEntries({ - anyOf: [ - { - type: 'object', - properties: { - value: { type: 'string' }, + expect( + extractJsonObjectSchemaEntries({ + anyOf: [ + { + type: 'object', + properties: { + value: { type: 'string' }, + }, }, - }, - { - type: 'object', - properties: { - value: { type: 'number' }, + { + type: 'object', + properties: { + value: { type: 'number' }, + }, }, - }, - ], - })).toEqual([ - ['value', { anyOf: [{ type: 'string' }, { type: 'number' }] }, true], - ]) + ], + }), + ).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' }, + expect( + extractJsonObjectSchemaEntries({ + allOf: [ + { + type: 'object', + properties: { + value: { type: 'string' }, + }, }, - }, - { - type: 'object', - properties: { - value: { minLength: 1 }, + { + type: 'object', + properties: { + value: { minLength: 1 }, + }, }, - }, - ], - })).toEqual([ - ['value', { allOf: [{ type: 'string' }, { minLength: 1 }] }, true], - ]) + ], + }), + ).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' }, + expect( + extractJsonObjectSchemaEntries({ + anyOf: [ + { + type: 'object', + properties: { + a: { type: 'string' }, + }, + required: ['a'], }, - required: ['a'], - }, - ], - allOf: [ - { - type: 'object', - properties: { - a: { type: 'string' }, + ], + allOf: [ + { + type: 'object', + properties: { + a: { type: 'string' }, + }, }, - }, - ], - })).toEqual([ - ['a', { type: 'string' }, false], - ]) + ], + }), + ).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' }, + expect( + extractJsonObjectSchemaEntries({ + anyOf: [ + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p1', 'p2'], }, - required: ['p1', 'p2'], - }, - { - type: 'object', - properties: { - p1: { type: 'boolean' }, - p2: { type: 'boolean' }, - p3: { type: 'boolean' }, + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p1', 'p3'], }, - required: ['p1', 'p3'], - }, - { - type: 'object', - properties: { - p1: { type: 'boolean' }, - p2: { type: 'boolean' }, - p3: { type: 'boolean' }, + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p1', 'p2', 'p3'], }, - required: ['p1', 'p2', 'p3'], - }, - ], - })).toEqual([ + ], + }), + ).toEqual([ ['p1', { type: 'boolean' }, false], ['p2', { type: 'boolean' }, true], ['p3', { type: 'boolean' }, true], @@ -272,36 +287,38 @@ describe('extractJsonObjectSchemaEntries', () => { }) 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' }, + expect( + extractJsonObjectSchemaEntries({ + allOf: [ + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p2'], }, - required: ['p2'], - }, - { - type: 'object', - properties: { - p1: { type: 'boolean' }, - p2: { type: 'boolean' }, - p3: { type: 'boolean' }, + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p3'], }, - required: ['p3'], - }, - { - type: 'object', - properties: { - p1: { type: 'boolean' }, - p2: { type: 'boolean' }, - p3: { type: 'boolean' }, + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, }, - }, - ], - })).toEqual([ + ], + }), + ).toEqual([ ['p1', { type: 'boolean' }, true], ['p2', { type: 'boolean' }, false], ['p3', { type: 'boolean' }, false], @@ -322,42 +339,52 @@ describe('extractJsonObjectSchemaEntries', () => { } expect(extractJsonObjectSchemaEntries(schema)).toEqual([ - ['self', { - $ref: '#/$defs/__schema0', - $defs: { - Leaf: { type: 'string' }, - __schema0: { - type: 'object', - properties: { - self: { $ref: '#/$defs/__schema0' }, - leaf: { $ref: '#/$defs/Leaf' }, + [ + 'self', + { + $ref: '#/$defs/__schema0', + $defs: { + Leaf: { type: 'string' }, + __schema0: { + type: 'object', + properties: { + self: { $ref: '#/$defs/__schema0' }, + leaf: { $ref: '#/$defs/Leaf' }, + }, + required: ['self'], }, - required: ['self'], }, }, - }, false], - ['leaf', { - $ref: '#/$defs/Leaf', - $defs: { - Leaf: { type: 'string' }, - __schema0: { - type: 'object', - properties: { - self: { $ref: '#/$defs/__schema0' }, - leaf: { $ref: '#/$defs/Leaf' }, + false, + ], + [ + 'leaf', + { + $ref: '#/$defs/Leaf', + $defs: { + Leaf: { type: 'string' }, + __schema0: { + type: 'object', + properties: { + self: { $ref: '#/$defs/__schema0' }, + leaf: { $ref: '#/$defs/Leaf' }, + }, + required: ['self'], }, - required: ['self'], }, }, - }, true], + true, + ], ]) }) it('stops on cyclic alias refs', () => { - expect(extractJsonObjectSchemaEntries({ - $ref: '#/$defs/A', - $defs: { A: { $ref: '#/$defs/B' }, B: { $ref: '#/$defs/A' } }, - })).toEqual([]) + expect( + extractJsonObjectSchemaEntries({ + $ref: '#/$defs/A', + $defs: { A: { $ref: '#/$defs/B' }, B: { $ref: '#/$defs/A' } }, + }), + ).toEqual([]) }) it('extracts properties from recursive $ref nested unions and intersections', () => { @@ -422,22 +449,24 @@ describe('extractJsonObjectSchemaEntries', () => { }) 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([ + 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], @@ -445,22 +474,24 @@ describe('extractJsonObjectSchemaEntries', () => { }) 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([ + 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], @@ -468,36 +499,38 @@ describe('extractJsonObjectSchemaEntries', () => { }) 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' }, + expect( + extractJsonObjectSchemaEntries({ + allOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + shared: { type: 'string' }, + left: { type: 'boolean' }, + }, + required: ['shared', 'left'], }, - required: ['shared', 'left'], - }, - { - type: 'object', - properties: { - shared: { type: 'string' }, - right: { type: 'number' }, + { + type: 'object', + properties: { + shared: { type: 'string' }, + right: { type: 'number' }, + }, + required: ['shared'], }, - required: ['shared'], + ], + }, + { + properties: { + extra: { type: 'null' }, }, - ], - }, - { - properties: { - extra: { type: 'null' }, + required: ['extra'], }, - required: ['extra'], - }, - ], - })).toEqual([ + ], + }), + ).toEqual([ ['shared', { type: 'string' }, false], ['left', { type: 'boolean' }, true], ['right', { type: 'number' }, true], @@ -506,41 +539,41 @@ describe('extractJsonObjectSchemaEntries', () => { }) 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' }, - }, + expect( + extractJsonObjectSchemaEntries({ + type: 'object', + properties: { + a: { type: 'string' }, }, - ], - })).toEqual([ + required: ['a'], + allOf: [ + { + type: 'object', + properties: { + b: { type: 'number' }, + }, + }, + ], + }), + ).toEqual([ ['a', { type: 'string' }, false], ['b', { type: 'number' }, true], ]) }) it('applies top-level required to properties only composition branches declare', () => { - expect(extractJsonObjectSchemaEntries({ - type: 'object', - required: ['a', 'b', 'c'], - anyOf: [ - { type: 'object', properties: { a: { type: 'string' } } }, - { type: 'object', properties: { a: { type: 'number' } } }, - ], - oneOf: [ - { type: 'object', properties: { b: { type: 'string' } } }, - ], - allOf: [ - { type: 'object', properties: { c: { type: 'string' }, d: { type: 'string' } } }, - ], - })).toEqual([ + expect( + extractJsonObjectSchemaEntries({ + type: 'object', + required: ['a', 'b', 'c'], + anyOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { a: { type: 'number' } } }, + ], + oneOf: [{ type: 'object', properties: { b: { type: 'string' } } }], + allOf: [{ type: 'object', properties: { c: { type: 'string' }, d: { type: 'string' } } }], + }), + ).toEqual([ ['a', { anyOf: [{ type: 'string' }, { type: 'number' }] }, false], ['b', { type: 'string' }, false], ['c', { type: 'string' }, false], @@ -548,111 +581,131 @@ describe('extractJsonObjectSchemaEntries', () => { ]) // the same inside a nested composition - expect(extractJsonObjectSchemaEntries({ - allOf: [ - { - required: ['a'], - anyOf: [ - { type: 'object', properties: { a: { type: 'string' } } }, - { type: 'object', properties: { b: { type: 'string' } } }, - ], - }, - ], - })).toEqual([ + expect( + extractJsonObjectSchemaEntries({ + allOf: [ + { + required: ['a'], + anyOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'string' } } }, + ], + }, + ], + }), + ).toEqual([ ['a', { type: 'string' }, false], ['b', { type: 'string' }, 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' }, - }, + expect( + extractJsonObjectSchemaEntries({ + type: 'object', + properties: { + a: { type: 'string' }, }, - ], - })).toEqual([ + 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' }, + expect( + extractJsonObjectSchemaEntries({ + anyOf: [ + { type: 'object' }, + { + type: 'object', + properties: { + a: { type: 'string' }, + }, }, - }, - ], - })).toEqual([ - ['a', { type: 'string' }, true], - ]) + ], + }), + ).toEqual([['a', { type: 'string' }, true]]) }) it('sorts anyOf and oneOf groups after allOf groups', () => { - expect(extractJsonObjectSchemaEntries({ - anyOf: [ - { - type: 'object', - properties: { - shared: { type: 'string' }, + expect( + extractJsonObjectSchemaEntries({ + anyOf: [ + { + type: 'object', + properties: { + shared: { type: 'string' }, + }, }, - }, - ], - oneOf: [ - { - type: 'object', - properties: { - shared: { type: 'number' }, + ], + oneOf: [ + { + type: 'object', + properties: { + shared: { type: 'number' }, + }, }, - }, - ], - })).toEqual([ - ['shared', { allOf: [{ type: 'string' }, { type: 'number' }] }, true], - ]) + ], + }), + ).toEqual([['shared', { allOf: [{ type: 'string' }, { type: 'number' }] }, true]]) }) }) describe('extractJsonObjectSchemaAdditionalProperties', () => { it('returns undefined when the root declares no additionalProperties', () => { expect(extractJsonObjectSchemaAdditionalProperties(true)).toBeUndefined() - expect(extractJsonObjectSchemaAdditionalProperties({ type: 'object', properties: { a: { type: 'string' } } })).toBeUndefined() + expect( + extractJsonObjectSchemaAdditionalProperties({ + type: 'object', + properties: { a: { type: 'string' } }, + }), + ).toBeUndefined() }) it('returns the root additionalProperties', () => { - expect(extractJsonObjectSchemaAdditionalProperties({ type: 'object', additionalProperties: false })).toBe(false) - expect(extractJsonObjectSchemaAdditionalProperties({ type: 'object', additionalProperties: { type: 'number' } })).toEqual({ type: 'number' }) + expect( + extractJsonObjectSchemaAdditionalProperties({ type: 'object', additionalProperties: false }), + ).toBe(false) + expect( + extractJsonObjectSchemaAdditionalProperties({ + type: 'object', + additionalProperties: { type: 'number' }, + }), + ).toEqual({ type: 'number' }) }) it('ignores additionalProperties declared only by composition branches', () => { - expect(extractJsonObjectSchemaAdditionalProperties({ - anyOf: [ - { type: 'object', properties: { a: { type: 'string' } }, additionalProperties: false }, - { type: 'object', properties: { b: { type: 'string' } } }, - ], - })).toBeUndefined() + expect( + extractJsonObjectSchemaAdditionalProperties({ + anyOf: [ + { type: 'object', properties: { a: { type: 'string' } }, additionalProperties: false }, + { type: 'object', properties: { b: { type: 'string' } } }, + ], + }), + ).toBeUndefined() }) it('resolves local refs and preserves root $defs', () => { - expect(extractJsonObjectSchemaAdditionalProperties({ - $ref: '#/$defs/Thing', - $defs: { - Thing: { $ref: '#/$defs/Strict' }, - Strict: { type: 'object', additionalProperties: { $ref: '#/$defs/Value' } }, - Value: { type: 'number' }, - }, - })).toEqual({ + expect( + extractJsonObjectSchemaAdditionalProperties({ + $ref: '#/$defs/Thing', + $defs: { + Thing: { $ref: '#/$defs/Strict' }, + Strict: { type: 'object', additionalProperties: { $ref: '#/$defs/Value' } }, + Value: { type: 'number' }, + }, + }), + ).toEqual({ $ref: '#/$defs/Value', $defs: { Thing: { $ref: '#/$defs/Strict' }, @@ -663,10 +716,12 @@ describe('extractJsonObjectSchemaAdditionalProperties', () => { }) it('uses hoisted defs for recursive root refs', () => { - expect(extractJsonObjectSchemaAdditionalProperties({ - type: 'object', - additionalProperties: { $ref: '#' }, - })).toEqual({ + expect( + extractJsonObjectSchemaAdditionalProperties({ + type: 'object', + additionalProperties: { $ref: '#' }, + }), + ).toEqual({ $ref: '#/$defs/__schema0', $defs: { __schema0: { @@ -680,15 +735,21 @@ describe('extractJsonObjectSchemaAdditionalProperties', () => { 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({ + expect( + combineJsonObjectSchemaEntries([ + [ + 'requiredRef', + { + $ref: '#/$defs/Shared', + $defs: { + Shared: { type: 'string' }, + }, + }, + false, + ], + ['optionalNever', false, true], + ]), + ).toEqual({ type: 'object', properties: { requiredRef: { @@ -704,17 +765,23 @@ describe('combineJsonObjectSchemaEntries', () => { }) 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({ + expect( + combineJsonObjectSchemaEntries([ + [ + 'node', + { + type: 'object', + properties: { + self: { $ref: '#' }, + brother: { $ref: '#/properties/self' }, + unchanged: { $ref: '0/unchanged' }, + }, + required: ['self'], + }, + false, + ], + ]), + ).toEqual({ type: 'object', properties: { node: { @@ -732,22 +799,32 @@ describe('combineJsonObjectSchemaEntries', () => { }) 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({ + 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: { @@ -780,10 +857,12 @@ describe('combineJsonObjectSchemaEntries', () => { }, } - expect(combineJsonObjectSchemaEntries( - extractJsonObjectSchemaEntries(schema)!, - extractJsonObjectSchemaAdditionalProperties(schema), - )).toEqual(schema) + expect( + combineJsonObjectSchemaEntries( + extractJsonObjectSchemaEntries(schema)!, + extractJsonObjectSchemaAdditionalProperties(schema), + ), + ).toEqual(schema) }) it('sets additionalProperties when given', () => { @@ -796,18 +875,24 @@ describe('combineJsonObjectSchemaEntries', () => { }) it('promotes additionalProperties defs and rebases its root pointers', () => { - expect(combineJsonObjectSchemaEntries([ - ['name', { - $ref: '#/$defs/Shared', - $defs: { Shared: { type: 'string' } }, - }, false], - ], { - anyOf: [ - { $ref: '#/$defs/Shared' }, - { type: 'array', items: { $ref: '#' } }, - ], - $defs: { Shared: { type: 'number' } }, - })).toEqual({ + expect( + combineJsonObjectSchemaEntries( + [ + [ + 'name', + { + $ref: '#/$defs/Shared', + $defs: { Shared: { type: 'string' } }, + }, + false, + ], + ], + { + anyOf: [{ $ref: '#/$defs/Shared' }, { type: 'array', items: { $ref: '#' } }], + $defs: { Shared: { type: 'number' } }, + }, + ), + ).toEqual({ type: 'object', properties: { name: { $ref: '#/$defs/Shared' }, @@ -827,49 +912,56 @@ describe('combineJsonObjectSchemaEntries', () => { }) 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' }, + 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, }, }, - Shared2: { - type: 'boolean', - }, - Equal: { - type: 'null', - }, - Missing: undefined as any, - }, - }, true], - ['second', { - allOf: [ - { $ref: '#/$defs/Shared' }, - { $ref: '#/$defs/Shared/properties/value' }, + true, ], - $defs: { - Shared: { - type: 'object', - properties: { - value: { type: 'number' }, + [ + 'second', + { + allOf: [{ $ref: '#/$defs/Shared' }, { $ref: '#/$defs/Shared/properties/value' }], + $defs: { + Shared: { + type: 'object', + properties: { + value: { type: 'number' }, + }, + }, + Equal: { + type: 'null', + }, }, }, - Equal: { - type: 'null', - }, - }, - }, true], - ])).toEqual({ + true, + ], + ]), + ).toEqual({ type: 'object', properties: { first: { @@ -882,10 +974,7 @@ describe('combineJsonObjectSchemaEntries', () => { ], }, second: { - allOf: [ - { $ref: '#/$defs/Shared3' }, - { $ref: '#/$defs/Shared3/properties/value' }, - ], + allOf: [{ $ref: '#/$defs/Shared3' }, { $ref: '#/$defs/Shared3/properties/value' }], }, }, $defs: { @@ -925,14 +1014,18 @@ describe('combineJsonObjectSchemaEntries', () => { Sibling: { $ref: '#/properties/value' }, Dangling: { $ref: '#/nonExists' }, Leaf: { type: 'string' }, - Wrapper: { allOf: [{ $ref: '#/$defs/Leaf' }, { $ref: '#/$defs/Self' }, { $ref: '#/$defs/Sibling' }] }, + Wrapper: { + allOf: [{ $ref: '#/$defs/Leaf' }, { $ref: '#/$defs/Self' }, { $ref: '#/$defs/Sibling' }], + }, }, } - expect(combineJsonObjectSchemaEntries([ - ['left', entry, false], - ['right', entry, true], - ])).toEqual({ + expect( + combineJsonObjectSchemaEntries([ + ['left', entry, false], + ['right', entry, true], + ]), + ).toEqual({ type: 'object', properties: { left: { @@ -952,24 +1045,38 @@ describe('combineJsonObjectSchemaEntries', () => { Sibling: { $ref: '#/properties/left/properties/value' }, Dangling: { $ref: '#/nonExists' }, Leaf: { type: 'string' }, - Wrapper: { allOf: [{ $ref: '#/$defs/Leaf' }, { $ref: '#/$defs/Self' }, { $ref: '#/$defs/Sibling' }] }, + Wrapper: { + allOf: [{ $ref: '#/$defs/Leaf' }, { $ref: '#/$defs/Self' }, { $ref: '#/$defs/Sibling' }], + }, Self2: { $ref: '#/properties/right' }, Sibling2: { $ref: '#/properties/right/properties/value' }, - Wrapper2: { allOf: [{ $ref: '#/$defs/Leaf' }, { $ref: '#/$defs/Self2' }, { $ref: '#/$defs/Sibling2' }] }, + Wrapper2: { + allOf: [ + { $ref: '#/$defs/Leaf' }, + { $ref: '#/$defs/Self2' }, + { $ref: '#/$defs/Sibling2' }, + ], + }, }, }) }) it('encodes the property name when rebasing entry root pointers', () => { - expect(combineJsonObjectSchemaEntries([ - ['a/b', { - type: 'object', - properties: { - self: { $ref: '#' }, - deep: { $ref: '#/properties/self' }, - }, - }, false], - ])).toEqual({ + expect( + combineJsonObjectSchemaEntries([ + [ + 'a/b', + { + type: 'object', + properties: { + self: { $ref: '#' }, + deep: { $ref: '#/properties/self' }, + }, + }, + false, + ], + ]), + ).toEqual({ type: 'object', properties: { 'a/b': { @@ -986,10 +1093,16 @@ describe('combineJsonObjectSchemaEntries', () => { it('keeps a property named __proto__ as an own key', () => { // an own __proto__ key can neither be written as an object literal nor compared with toEqual - expect(JSON.stringify(combineJsonObjectSchemaEntries([ - ['__proto__', { type: 'string' }, false], - ['safe', { type: 'number' }, true], - ]))).toBe('{"type":"object","properties":{"__proto__":{"type":"string"},"safe":{"type":"number"}},"required":["__proto__"]}') + expect( + JSON.stringify( + combineJsonObjectSchemaEntries([ + ['__proto__', { type: 'string' }, false], + ['safe', { type: 'number' }, true], + ]), + ), + ).toBe( + '{"type":"object","properties":{"__proto__":{"type":"string"},"safe":{"type":"number"}},"required":["__proto__"]}', + ) }) }) @@ -1005,7 +1118,12 @@ describe('flattenJsonUnionSchema', () => { { type: 'number' }, ]) - expect(flattenJsonUnionSchema({ description: 'metadata', oneOf: [{ type: 'boolean' }, { type: 'null' }] })).toEqual([ + expect( + flattenJsonUnionSchema({ + description: 'metadata', + oneOf: [{ type: 'boolean' }, { type: 'null' }], + }), + ).toEqual([ { description: 'metadata', type: 'boolean' }, { description: 'metadata', type: 'null' }, ]) @@ -1144,7 +1262,9 @@ describe('flattenJsonUnionSchema', () => { it('flattening recursive union $ref branches', () => { const schema = { - $defs: { Shared: { anyOf: [{ type: 'number' }, { type: 'boolean' }, { $ref: '#/$defs/Shared' }] } }, + $defs: { + Shared: { anyOf: [{ type: 'number' }, { type: 'boolean' }, { $ref: '#/$defs/Shared' }] }, + }, anyOf: [{ $ref: '#/$defs/Shared' }, { type: 'string' }], } satisfies JsonSchema @@ -1200,101 +1320,128 @@ describe('flattenJsonUnionSchema', () => { 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([ + 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([ + 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' }, + { + type: 'array', + items: { type: 'string', description: 'ignore2' }, + description: 'many strings', + }, ]) - expect(matchArrayableJsonSchema({ anyOf: [{ type: 'array' }, { }] })).toEqual([{}, { type: 'array' }]) + 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' }) + 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, - ], + 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' }, + expect( + combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], }, - required: ['left'], }, }, - }, - { - type: 'object', - properties: { - right: { $ref: '#/$defs/SharedRight' }, - }, - required: ['right'], - $defs: { - SharedRight: { - type: 'object', - properties: { - right: { type: 'number' }, + { + type: 'object', + properties: { + right: { $ref: '#/$defs/SharedRight' }, + }, + required: ['right'], + $defs: { + SharedRight: { + type: 'object', + properties: { + right: { type: 'number' }, + }, + required: ['right'], }, - required: ['right'], }, }, - }, - { - type: 'object', - properties: { - third: { $ref: '#/$defs/SharedThird' }, - }, - required: ['third'], - $defs: { - SharedThird: { - type: 'object', - properties: { - third: { type: 'boolean' }, + { + type: 'object', + properties: { + third: { $ref: '#/$defs/SharedThird' }, + }, + required: ['third'], + $defs: { + SharedThird: { + type: 'object', + properties: { + third: { type: 'boolean' }, + }, + required: ['third'], }, - required: ['third'], }, }, - }, - ])).toEqual({ + ]), + ).toEqual({ allOf: [ { type: 'object', @@ -1345,86 +1492,87 @@ describe('combineJsonSchemasWithComposition', () => { }) 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' }, + expect( + combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], }, - 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' }, + { + 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' }, + // oxlint-disable-next-line unicorn/no-thenable + 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'], }, - required: ['right'], }, + examples: [{ $ref: '#/$defs/Shared' }], }, - examples: [ - { $ref: '#/$defs/Shared' }, - ], - }, - { - type: 'object', - properties: { - duplicate: { $ref: '#/$defs/Shared' }, - }, - required: ['duplicate'], - $defs: { - Shared: { - type: 'object', - properties: { - left: { type: 'string' }, + { + type: 'object', + properties: { + duplicate: { $ref: '#/$defs/Shared' }, + }, + required: ['duplicate'], + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], }, - required: ['left'], }, }, - }, - { - type: 'object', - properties: { - third: { $ref: '#/$defs/Shared' }, - }, - required: ['third'], - $defs: { - Shared: { - type: 'object', - properties: { - third: { type: 'boolean' }, + { + type: 'object', + properties: { + third: { $ref: '#/$defs/Shared' }, + }, + required: ['third'], + $defs: { + Shared: { + type: 'object', + properties: { + third: { type: 'boolean' }, + }, + required: ['third'], }, - required: ['third'], }, }, - }, - ])).toEqual({ + ]), + ).toEqual({ allOf: [ { type: 'object', @@ -1442,6 +1590,7 @@ describe('combineJsonSchemasWithComposition', () => { additionalProperties: { $ref: '#/$defs/Shared2' }, not: { $ref: '#/$defs/Shared2' }, if: { $ref: '#/$defs/Shared2' }, + // oxlint-disable-next-line unicorn/no-thenable then: { $ref: '#/$defs/Shared2' }, else: { $ref: '#/$defs/Shared2' }, prefixItems: [{ $ref: '#/$defs/Shared2' }], @@ -1450,9 +1599,7 @@ describe('combineJsonSchemasWithComposition', () => { rightDeep: { $ref: '#/$defs/Shared2/properties/right' }, }, required: ['right'], - examples: [ - { $ref: '#/$defs/Shared' }, - ], + examples: [{ $ref: '#/$defs/Shared' }], }, { type: 'object', @@ -1496,40 +1643,42 @@ describe('combineJsonSchemasWithComposition', () => { }) 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'], + 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' }, + { + type: 'object', + properties: { + right: { $ref: '#/$defs/Shared' }, + }, + required: ['right'], + $defs: { + Shared: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], }, - required: ['value'], }, }, - }, - ])).toEqual({ + ]), + ).toEqual({ allOf: [ { type: 'object', @@ -1560,25 +1709,24 @@ describe('combineJsonSchemasWithComposition', () => { it('only dedupes against the def sharing its name', () => { // the renamed body is byte-identical to `B`, but `B` is not a dedupe candidate for `A` - expect(combineJsonSchemasWithComposition('anyOf', [ - { - allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/B' }], - $defs: { - A: { type: 'string' }, - B: { type: 'number' }, + expect( + combineJsonSchemasWithComposition('anyOf', [ + { + allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/B' }], + $defs: { + A: { type: 'string' }, + B: { type: 'number' }, + }, }, - }, - { - $ref: '#/$defs/A', - $defs: { - A: { type: 'number' }, + { + $ref: '#/$defs/A', + $defs: { + A: { type: 'number' }, + }, }, - }, - ])).toEqual({ - anyOf: [ - { allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/B' }] }, - { $ref: '#/$defs/A2' }, - ], + ]), + ).toEqual({ + anyOf: [{ allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/B' }] }, { $ref: '#/$defs/A2' }], $defs: { A: { type: 'string' }, B: { type: 'number' }, @@ -1588,26 +1736,25 @@ describe('combineJsonSchemasWithComposition', () => { }) it('rewrites refs inside promoted def bodies so renamed defs stay reachable', () => { - expect(combineJsonSchemasWithComposition('anyOf', [ - { - $ref: '#/$defs/Wrapper', - $defs: { - Leaf: { type: 'string' }, - Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, + expect( + combineJsonSchemasWithComposition('anyOf', [ + { + $ref: '#/$defs/Wrapper', + $defs: { + Leaf: { type: 'string' }, + Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, + }, }, - }, - { - $ref: '#/$defs/Wrapper', - $defs: { - Leaf: { type: 'number' }, - Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, + { + $ref: '#/$defs/Wrapper', + $defs: { + Leaf: { type: 'number' }, + Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, + }, }, - }, - ])).toEqual({ - anyOf: [ - { $ref: '#/$defs/Wrapper' }, - { $ref: '#/$defs/Wrapper2' }, - ], + ]), + ).toEqual({ + anyOf: [{ $ref: '#/$defs/Wrapper' }, { $ref: '#/$defs/Wrapper2' }], $defs: { Leaf: { type: 'string' }, Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, @@ -1617,26 +1764,25 @@ describe('combineJsonSchemasWithComposition', () => { }) // referencing defs still dedupe when everything they point at dedupes too - expect(combineJsonSchemasWithComposition('anyOf', [ - { - $ref: '#/$defs/Wrapper', - $defs: { - Leaf: { type: 'string' }, - Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, + expect( + combineJsonSchemasWithComposition('anyOf', [ + { + $ref: '#/$defs/Wrapper', + $defs: { + Leaf: { type: 'string' }, + Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, + }, }, - }, - { - $ref: '#/$defs/Wrapper', - $defs: { - Leaf: { type: 'string' }, - Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, + { + $ref: '#/$defs/Wrapper', + $defs: { + Leaf: { type: 'string' }, + Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, + }, }, - }, - ])).toEqual({ - anyOf: [ - { $ref: '#/$defs/Wrapper' }, - { $ref: '#/$defs/Wrapper' }, - ], + ]), + ).toEqual({ + anyOf: [{ $ref: '#/$defs/Wrapper' }, { $ref: '#/$defs/Wrapper' }], $defs: { Leaf: { type: 'string' }, Wrapper: { type: 'array', items: { $ref: '#/$defs/Leaf' } }, @@ -1656,14 +1802,13 @@ describe('combineJsonSchemasWithComposition', () => { }, }) - expect(combineJsonSchemasWithComposition('anyOf', [ - chain({ type: 'string' }), - chain({ type: 'number' }), - ])).toEqual({ - anyOf: [ - { $ref: '#/$defs/Outer' }, - { $ref: '#/$defs/Outer2' }, - ], + expect( + combineJsonSchemasWithComposition('anyOf', [ + chain({ type: 'string' }), + chain({ type: 'number' }), + ]), + ).toEqual({ + anyOf: [{ $ref: '#/$defs/Outer' }, { $ref: '#/$defs/Outer2' }], $defs: { Outer: { type: 'object', properties: { value: { $ref: '#/$defs/Middle' } } }, Middle: { type: 'array', items: { $ref: '#/$defs/Inner' } }, @@ -1676,21 +1821,23 @@ describe('combineJsonSchemasWithComposition', () => { }) it('never renames a def onto a name another def in the same branch already claims', () => { - expect(combineJsonSchemasWithComposition('anyOf', [ - { - $ref: '#/$defs/Shared', - $defs: { - Shared: { type: 'string' }, + expect( + combineJsonSchemasWithComposition('anyOf', [ + { + $ref: '#/$defs/Shared', + $defs: { + Shared: { type: 'string' }, + }, }, - }, - { - allOf: [{ $ref: '#/$defs/Shared' }, { $ref: '#/$defs/Shared2' }], - $defs: { - Shared: { type: 'number' }, - Shared2: { type: 'boolean' }, + { + allOf: [{ $ref: '#/$defs/Shared' }, { $ref: '#/$defs/Shared2' }], + $defs: { + Shared: { type: 'number' }, + Shared2: { type: 'boolean' }, + }, }, - }, - ])).toEqual({ + ]), + ).toEqual({ anyOf: [ { $ref: '#/$defs/Shared' }, { allOf: [{ $ref: '#/$defs/Shared3' }, { $ref: '#/$defs/Shared2' }] }, @@ -1704,23 +1851,25 @@ describe('combineJsonSchemasWithComposition', () => { }) it('promotes def names that shadow Object.prototype members', () => { - expect(combineJsonSchemasWithComposition('anyOf', [ - { - allOf: [{ $ref: '#/$defs/toString' }, { $ref: '#/$defs/valueOf' }], - $defs: { - // Object.prototype member names get a method contextual type, `satisfies` keeps `type` literal - toString: { type: 'string' } satisfies JsonSchema, - constructor: { type: 'boolean' } satisfies JsonSchema, + expect( + combineJsonSchemasWithComposition('anyOf', [ + { + allOf: [{ $ref: '#/$defs/toString' }, { $ref: '#/$defs/valueOf' }], + $defs: { + // Object.prototype member names get a method contextual type, `satisfies` keeps `type` literal + toString: { type: 'string' } satisfies JsonSchema, + constructor: { type: 'boolean' } satisfies JsonSchema, + }, }, - }, - { - $ref: '#/$defs/toString', - $defs: { - toString: { type: 'number' } satisfies JsonSchema, - constructor: { type: 'boolean' } satisfies JsonSchema, + { + $ref: '#/$defs/toString', + $defs: { + toString: { type: 'number' } satisfies JsonSchema, + constructor: { type: 'boolean' } satisfies JsonSchema, + }, }, - }, - ])).toEqual({ + ]), + ).toEqual({ anyOf: [ { allOf: [{ $ref: '#/$defs/toString' }, { $ref: '#/$defs/valueOf' }] }, { $ref: '#/$defs/toString2' }, @@ -1733,31 +1882,41 @@ describe('combineJsonSchemasWithComposition', () => { }) // a def literally named __proto__ has to survive as an own key - expect(JSON.stringify(combineJsonSchemasWithComposition('anyOf', [ - JSON.parse('{"$ref":"#/$defs/__proto__","$defs":{"__proto__":{"type":"string"}}}'), - { type: 'object' }, - ]))).toBe('{"anyOf":[{"$ref":"#/$defs/__proto__"},{"type":"object"}],"$defs":{"__proto__":{"type":"string"}}}') + expect( + JSON.stringify( + combineJsonSchemasWithComposition('anyOf', [ + JSON.parse('{"$ref":"#/$defs/__proto__","$defs":{"__proto__":{"type":"string"}}}'), + { type: 'object' }, + ]), + ), + ).toBe( + '{"anyOf":[{"$ref":"#/$defs/__proto__"},{"type":"object"}],"$defs":{"__proto__":{"type":"string"}}}', + ) }) it('never lets two renames in the same branch land on the same target', () => { // A skips the taken A2..A11 and lands on A12, which A1 would otherwise take as its own first choice const taken = Object.fromEntries( - ['A', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'A10', 'A11'].map((name): [string, JsonSchema] => [name, { type: 'string' }]), + ['A', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'A10', 'A11'].map( + (name): [string, JsonSchema] => [name, { type: 'string' }], + ), ) - expect(combineJsonSchemasWithComposition('anyOf', [ - { - allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/A1' }], - $defs: taken, - }, - { - allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/A1' }], - $defs: { - A: { type: 'number' }, - A1: { type: 'boolean' }, + expect( + combineJsonSchemasWithComposition('anyOf', [ + { + allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/A1' }], + $defs: taken, }, - }, - ])).toEqual({ + { + allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/A1' }], + $defs: { + A: { type: 'number' }, + A1: { type: 'boolean' }, + }, + }, + ]), + ).toEqual({ anyOf: [ { allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/A1' }] }, { allOf: [{ $ref: '#/$defs/A12' }, { $ref: '#/$defs/A13' }] }, @@ -1812,36 +1971,38 @@ describe('combineJsonSchemasWithComposition', () => { }) 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' }, + 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' }, + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], }, - required: ['left'], }, }, - }, - { type: 'object' }, - ])).toEqual({ + { type: 'object' }, + ]), + ).toEqual({ allOf: [ { type: 'object', @@ -1876,16 +2037,18 @@ describe('combineJsonSchemasWithComposition', () => { }) it('decodes encoded segments when rebasing root pointers', () => { - expect(combineJsonSchemasWithComposition('anyOf', [ - { - type: 'object', - properties: { - 'a/b': { type: 'string' }, - 'ref': { $ref: '#/properties/a~1b' }, + expect( + combineJsonSchemasWithComposition('anyOf', [ + { + type: 'object', + properties: { + 'a/b': { type: 'string' }, + 'ref': { $ref: '#/properties/a~1b' }, + }, }, - }, - { type: 'object' }, - ])).toEqual({ + { type: 'object' }, + ]), + ).toEqual({ anyOf: [ { type: 'object', @@ -1900,23 +2063,25 @@ describe('combineJsonSchemasWithComposition', () => { }) 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', + 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, }, - 'Flag': true, }, - }, - { type: 'object' }, - ])).toEqual({ + { type: 'object' }, + ]), + ).toEqual({ allOf: [ { type: 'object', @@ -1938,20 +2103,22 @@ describe('combineJsonSchemasWithComposition', () => { }) it('renames encoded def names and re-encodes the rewritten refs', () => { - expect(combineJsonSchemasWithComposition('anyOf', [ - { - allOf: [{ $ref: '#/$defs/path~1name' }, { $ref: '#/$defs/path~1name/properties/x' }], - $defs: { - 'path/name': { type: 'object', properties: { x: { type: 'string' } } }, + expect( + combineJsonSchemasWithComposition('anyOf', [ + { + allOf: [{ $ref: '#/$defs/path~1name' }, { $ref: '#/$defs/path~1name/properties/x' }], + $defs: { + 'path/name': { type: 'object', properties: { x: { type: 'string' } } }, + }, }, - }, - { - allOf: [{ $ref: '#/$defs/path~1name' }, { $ref: '#/$defs/path~1name/properties/x' }], - $defs: { - 'path/name': { type: 'object', properties: { x: { type: 'number' } } }, + { + allOf: [{ $ref: '#/$defs/path~1name' }, { $ref: '#/$defs/path~1name/properties/x' }], + $defs: { + 'path/name': { type: 'object', properties: { x: { type: 'number' } } }, + }, }, - }, - ])).toEqual({ + ]), + ).toEqual({ anyOf: [ { allOf: [{ $ref: '#/$defs/path~1name' }, { $ref: '#/$defs/path~1name/properties/x' }] }, { allOf: [{ $ref: '#/$defs/path~1name2' }, { $ref: '#/$defs/path~1name2/properties/x' }] }, @@ -1964,20 +2131,22 @@ describe('combineJsonSchemasWithComposition', () => { }) 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, + expect( + combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + value: { $ref: '#/$defs/Real' }, + }, + $defs: { + Real: { type: 'string' }, + Missing: undefined as any, + }, }, - }, - { type: 'object' }, - // toEqual would treat a `Missing: undefined` key as absent, so this has to be strict - ])).toStrictEqual({ + { type: 'object' }, + // toEqual would treat a `Missing: undefined` key as absent, so this has to be strict + ]), + ).toStrictEqual({ allOf: [ { type: 'object', @@ -1994,18 +2163,20 @@ describe('combineJsonSchemasWithComposition', () => { }) 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' }, + expect( + combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + child: { $ref: '#' }, + brother: { $ref: '#/properties/child' }, + nonExists: { $ref: '#/nonExists' }, + }, + required: ['child'], }, - required: ['child'], - }, - { type: 'object' }, - ])).toEqual({ + { type: 'object' }, + ]), + ).toEqual({ allOf: [ { type: 'object', @@ -2022,28 +2193,30 @@ describe('combineJsonSchemasWithComposition', () => { }) it('supports anyOf', () => { - expect(combineJsonSchemasWithComposition('anyOf', [ - { - type: 'object', - properties: { - left: { $ref: '#/$defs/Shared' }, - }, - required: ['left'], - $defs: { - Shared: { - type: 'string', + 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' }, + { + type: 'object', + properties: { + right: { $ref: '#' }, + nested: { $ref: '#/properties/right' }, + }, + required: ['right', 'nested'], }, - required: ['right', 'nested'], - }, - ])).toEqual({ + ]), + ).toEqual({ anyOf: [ { type: 'object', @@ -2070,39 +2243,41 @@ describe('combineJsonSchemasWithComposition', () => { }) it('supports oneOf', () => { - expect(combineJsonSchemasWithComposition('oneOf', [ - { - type: 'object', - properties: { - left: { $ref: '#/$defs/Shared' }, - }, - required: ['left'], - $defs: { - Shared: { - type: 'string', + expect( + combineJsonSchemasWithComposition('oneOf', [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, }, - }, - }, - { - type: 'object', - properties: { - right: { $ref: '#/$defs/Shared' }, - childSchema: { - type: 'object', - properties: { - nested: { $ref: '#/properties/right' }, + required: ['left'], + $defs: { + Shared: { + type: 'string', }, - required: ['nested'], }, }, - required: ['right', 'childSchema'], - $defs: { - Shared: { - type: 'number', + { + 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({ + ]), + ).toEqual({ oneOf: [ { type: 'object', @@ -2142,49 +2317,40 @@ 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' }] }, - ]) + 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' }, - ]) + 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' }, + expect( + deduplicateJsonSchemas([ + { + $ref: '#/$defs/Shared', + $defs: { + Shared: { type: 'string' }, + }, }, - }, - { - $ref: '#/$defs/Shared', - $defs: { - Shared: { type: 'number' }, + { + $ref: '#/$defs/Shared', + $defs: { + Shared: { type: 'number' }, + }, }, - }, - ])).toEqual([ + ]), + ).toEqual([ { $ref: '#/$defs/Shared', $defs: { @@ -2203,11 +2369,13 @@ describe('deduplicateJsonSchemas', () => { it('keeps schemas whose date defaults are different instances', () => { const date = new Date(0) - expect(deduplicateJsonSchemas([ - { type: 'string', default: date }, - { type: 'string', default: new Date(1) }, - { type: 'string', default: date }, - ])).toEqual([ + expect( + deduplicateJsonSchemas([ + { type: 'string', default: date }, + { type: 'string', default: new Date(1) }, + { type: 'string', default: date }, + ]), + ).toEqual([ { type: 'string', default: new Date(0) }, { type: 'string', default: new Date(1) }, ]) @@ -2247,7 +2415,12 @@ describe('isJsonObjectLikeSchema', () => { expect(isJsonObjectLikeSchema({ $ref: '#/$defs/object', $defs })).toBe(true) expect(isJsonObjectLikeSchema({ $ref: '#/$defs/string', $defs })).toBe(false) - expect(isJsonObjectLikeSchema({ anyOf: [{ $ref: '#/$defs/object' }, { $ref: '#/$defs/string' }], $defs })).toBe(false) + expect( + isJsonObjectLikeSchema({ + anyOf: [{ $ref: '#/$defs/object' }, { $ref: '#/$defs/string' }], + $defs, + }), + ).toBe(false) // a cycle never proves the schema describes an object expect(isJsonObjectLikeSchema({ $ref: '#/$defs/union', $defs })).toBe(false) }) diff --git a/packages/json-schema/src/composition-utils.ts b/packages/json-schema/src/composition-utils.ts index 209957e836..70fa127ad5 100644 --- a/packages/json-schema/src/composition-utils.ts +++ b/packages/json-schema/src/composition-utils.ts @@ -3,12 +3,19 @@ * 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 } from './constants' -import { decodeJsonPointerSegment, encodeJsonPointerSegment, hoistRecursiveRefToDef, mapJsonSchemaRefs, resolveJsonSchemaRootLocalRef } from './ref-utils' -import { ensureJsonSchemaObject, isJsonArraySchema, isUnconstrainedSchema } from './utils' + +import { JSON_SCHEMA_LOGIC_KEYWORDS } from './constants.ts' +import { + decodeJsonPointerSegment, + encodeJsonPointerSegment, + hoistRecursiveRefToDef, + mapJsonSchemaRefs, + resolveJsonSchemaRootLocalRef, +} from './ref-utils.ts' +import type { JsonSchema } from './types.ts' +import type { JsonArraySchema, JsonObjectSchema } from './utils.ts' +import { ensureJsonSchemaObject, isJsonArraySchema, isUnconstrainedSchema } from './utils.ts' /** * Moves a schema's `$defs` into the shared root map, renaming only the names whose bodies @@ -43,7 +50,10 @@ function promoteJsonSchemaDefs( : ['#/$defs', encodeJsonPointerSegment(newName), ...segments].join('/') } - if (ref.startsWith('#/') && get(rootBody, ref.slice(2).split('/').map(decodeJsonPointerSegment)) !== undefined) { + if ( + ref.startsWith('#/') && + get(rootBody, ref.slice(2).split('/').map(decodeJsonPointerSegment)) !== undefined + ) { return `${basePath}/${ref.slice(2)}` } @@ -68,7 +78,11 @@ function promoteJsonSchemaDefs( const body = mapJsonSchemaRefs(def, mapRef) promoted.set(name, body) - if (renameMap.has(name) || !mergedDefs.has(name) || isDeepEqual(mergedDefs.get(name), body)) { + if ( + renameMap.has(name) || + !mergedDefs.has(name) || + isDeepEqual(mergedDefs.get(name), body) + ) { continue } @@ -108,9 +122,11 @@ export function combineJsonSchemasWithComposition( for (let i = 0; i < schemas.length; i++) { const schema = schemas[i]! - compositionBranches.push(typeof schema === 'boolean' - ? schema - : promoteJsonSchemaDefs(schema, mergedDefs, `#/${keyword}/${i}`)) + compositionBranches.push( + typeof schema === 'boolean' + ? schema + : promoteJsonSchemaDefs(schema, mergedDefs, `#/${keyword}/${i}`), + ) } const result: Exclude = { [keyword]: compositionBranches } @@ -127,7 +143,10 @@ export type JsonObjectSchemaEntry = [name: string, schema: JsonSchema, optional: * Combines object property entries back into a single object schema. * When given, `additionalProperties` constrains every property the entries do not name. */ -export function combineJsonObjectSchemaEntries(entries: JsonObjectSchemaEntry[], additionalProperties?: JsonSchema): JsonObjectSchema { +export function combineJsonObjectSchemaEntries( + entries: JsonObjectSchemaEntry[], + additionalProperties?: JsonSchema, +): JsonObjectSchema { const properties = new Map() const required: string[] = [] const mergedDefs = new Map() @@ -137,9 +156,16 @@ export function combineJsonObjectSchemaEntries(entries: JsonObjectSchemaEntry[], required.push(name) } - properties.set(name, typeof propertySchema === 'boolean' - ? propertySchema - : promoteJsonSchemaDefs(propertySchema, mergedDefs, `#/properties/${encodeJsonPointerSegment(name)}`)) + properties.set( + name, + typeof propertySchema === 'boolean' + ? propertySchema + : promoteJsonSchemaDefs( + propertySchema, + mergedDefs, + `#/properties/${encodeJsonPointerSegment(name)}`, + ), + ) } const schema: JsonObjectSchema = { @@ -152,9 +178,10 @@ export function combineJsonObjectSchemaEntries(entries: JsonObjectSchemaEntry[], } if (additionalProperties !== undefined) { - schema.additionalProperties = typeof additionalProperties === 'boolean' - ? additionalProperties - : promoteJsonSchemaDefs(additionalProperties, mergedDefs, '#/additionalProperties') + schema.additionalProperties = + typeof additionalProperties === 'boolean' + ? additionalProperties + : promoteJsonSchemaDefs(additionalProperties, mergedDefs, '#/additionalProperties') } if (mergedDefs.size > 0) { @@ -167,13 +194,19 @@ export function combineJsonObjectSchemaEntries(entries: JsonObjectSchemaEntry[], /** * Parses an object schema, or a composition of object schemas, into property entries. */ -export function extractJsonObjectSchemaEntries(schema: JsonSchema): JsonObjectSchemaEntry[] | undefined { +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()) + const result = extractJsonObjectSchemaEntriesInternal( + omit(schema, ['$defs']), + schema.$defs, + new Set(), + ) if (!result.objectLike) { return undefined @@ -187,7 +220,9 @@ export function extractJsonObjectSchemaEntries(schema: JsonSchema): JsonObjectSc * with the root `$defs` attached so it can be passed to {@link combineJsonObjectSchemaEntries}. * Composition branches are not inspected, since their keyword only constrains that branch. */ -export function extractJsonObjectSchemaAdditionalProperties(schema: JsonSchema): JsonSchema | undefined { +export function extractJsonObjectSchemaAdditionalProperties( + schema: JsonSchema, +): JsonSchema | undefined { schema = hoistRecursiveRefToDef(schema) if (typeof schema !== 'object') { return undefined @@ -202,7 +237,15 @@ export function extractJsonObjectSchemaAdditionalProperties(schema: JsonSchema): return withRootDefs(resolved.additionalProperties, schema.$defs) } -const JSON_SCHEMA_OBJECT_KEYWORDS = ['properties', 'patternProperties', 'additionalProperties', 'required', 'propertyNames', 'minProperties', 'maxProperties'] +const JSON_SCHEMA_OBJECT_KEYWORDS = [ + 'properties', + 'patternProperties', + 'additionalProperties', + 'required', + 'propertyNames', + 'minProperties', + 'maxProperties', +] /** * Returns true when an object can stand for every value the schema describes: the schema constrains objects, @@ -210,7 +253,11 @@ const JSON_SCHEMA_OBJECT_KEYWORDS = ['properties', 'patternProperties', 'additio * Unlike {@link extractJsonObjectSchemaEntries}, an unconstrained schema counts as object-like. */ export function isJsonObjectLikeSchema(schema: JsonSchema): boolean { - return isJsonObjectLikeSchemaInternal(schema, typeof schema === 'object' ? schema.$defs : undefined, new Set()) + return isJsonObjectLikeSchemaInternal( + schema, + typeof schema === 'object' ? schema.$defs : undefined, + new Set(), + ) } function isJsonObjectLikeSchemaInternal( @@ -237,7 +284,7 @@ function isJsonObjectLikeSchemaInternal( return resolved.type === 'object' } - if (JSON_SCHEMA_OBJECT_KEYWORDS.some(keyword => keyword in resolved)) { + if (JSON_SCHEMA_OBJECT_KEYWORDS.some((keyword) => keyword in resolved)) { return true } @@ -245,7 +292,7 @@ function isJsonObjectLikeSchemaInternal( const members = resolved[keyword] if (Array.isArray(members) && members.length > 0) { - return members.every(member => isJsonObjectLikeSchemaInternal(member, $defs, followedRefs)) + return members.every((member) => isJsonObjectLikeSchemaInternal(member, $defs, followedRefs)) } } @@ -274,11 +321,16 @@ function extractJsonObjectSchemaEntriesInternal( const resolved = resolveJsonSchemaRootLocalRef(schema, $defs) if (resolved !== schema) { - return extractJsonObjectSchemaEntriesInternal(resolved, $defs, new Set(resolvingRefs).add(schema.$ref)) + return extractJsonObjectSchemaEntriesInternal( + resolved, + $defs, + new Set(resolvingRefs).add(schema.$ref), + ) } } - const sources: Array<{ entries: JsonObjectSchemaEntry[], source: JsonObjectSchemaEntrySource }> = [] + const sources: Array<{ entries: JsonObjectSchemaEntry[]; source: JsonObjectSchemaEntrySource }> = + [] if (schema.properties) { sources.push({ @@ -293,10 +345,11 @@ function extractJsonObjectSchemaEntriesInternal( }) } - let objectLike = schema.type === 'object' - || schema.properties !== undefined - || schema.required !== undefined - || schema.additionalProperties !== undefined + 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] @@ -305,9 +358,11 @@ function extractJsonObjectSchemaEntriesInternal( continue } - const branchResults = branches.map(branch => extractJsonObjectSchemaEntriesInternal(branch, $defs, resolvingRefs)) + const branchResults = branches.map((branch) => + extractJsonObjectSchemaEntriesInternal(branch, $defs, resolvingRefs), + ) - if (branchResults.some(result => !result.objectLike)) { + if (branchResults.some((result) => !result.objectLike)) { return { entries: [], objectLike: false } } @@ -319,8 +374,7 @@ function extractJsonObjectSchemaEntriesInternal( if (entries) { entries.push(entry) - } - else { + } else { entriesByName.set(entry[0], [entry]) } } @@ -329,13 +383,14 @@ function extractJsonObjectSchemaEntriesInternal( 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] - }) + 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, @@ -351,7 +406,10 @@ function extractJsonObjectSchemaEntriesInternal( }) } - const sourceEntries = new Map>>() + const sourceEntries = new Map< + string, + Partial> + >() for (const { entries, source } of sources) { for (const entry of entries) { @@ -359,8 +417,7 @@ function extractJsonObjectSchemaEntriesInternal( if (existing) { existing[source] = entry - } - else { + } else { sourceEntries.set(entry[0], { [source]: entry }) } } @@ -368,18 +425,19 @@ function extractJsonObjectSchemaEntriesInternal( 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)) + 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 }, - !schema.required?.includes(name) - && [entries.allOf, entries.anyOf, entries.oneOf].every(entry => entry === undefined || entry[2]), + !schema.required?.includes(name) && + [entries.allOf, entries.anyOf, entries.oneOf].every( + (entry) => entry === undefined || entry[2], + ), ] satisfies JsonObjectSchemaEntry }), objectLike, @@ -409,7 +467,10 @@ function flattenJsonUnionSchemaInternal( const resolved = resolveJsonSchemaRootLocalRef(schema) if (resolved !== schema) { - const result = flattenJsonUnionSchemaInternal(resolved, new Set(resolvingRefs).add(schema.$ref)) + const result = flattenJsonUnionSchemaInternal( + resolved, + new Set(resolvingRefs).add(schema.$ref), + ) if (result.length > 1) { return result } @@ -445,7 +506,9 @@ function flattenJsonUnionSchemaInternal( /** * Matches a union made of a single item schema and its array form. */ -export function matchArrayableJsonSchema(schema: JsonSchema): undefined | [itemSchema: JsonSchema, arraySchema: JsonArraySchema] { +export function matchArrayableJsonSchema( + schema: JsonSchema, +): undefined | [itemSchema: JsonSchema, arraySchema: JsonArraySchema] { const schemas = flattenJsonUnionSchema(schema) if (schemas.length !== 2) { @@ -458,16 +521,18 @@ export function matchArrayableJsonSchema(schema: JsonSchema): undefined | [itemS } const items1 = arraySchema.items ?? true - const items2 = schemas.find(s => s !== arraySchema) as JsonSchema + 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)), + 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)), + Object.entries(ensureJsonSchemaObject(items2)).filter(([key]) => + JSON_SCHEMA_LOGIC_KEYWORDS.has(key), + ), ) if (!isDeepEqual(logicItem1, logicItem2)) { @@ -481,7 +546,7 @@ export function deduplicateJsonSchemas(schemas: JsonSchema[]): JsonSchema[] { const result: JsonSchema[] = [] for (const schema of schemas) { - if (result.some(i => isDeepEqual(i, schema))) { + if (result.some((i) => isDeepEqual(i, schema))) { continue } @@ -491,7 +556,10 @@ export function deduplicateJsonSchemas(schemas: JsonSchema[]): JsonSchema[] { return result } -function withRootDefs(schema: JsonSchema, $defs: Record | undefined): JsonSchema { +function withRootDefs( + schema: JsonSchema, + $defs: Record | undefined, +): JsonSchema { if (typeof schema === 'boolean' || !$defs) { return schema } diff --git a/packages/json-schema/src/constants.ts b/packages/json-schema/src/constants.ts index c9bfc1be6b..7a808bea7e 100644 --- a/packages/json-schema/src/constants.ts +++ b/packages/json-schema/src/constants.ts @@ -1,53 +1,66 @@ -import type { JsonSchemaKeywords } from './types' +import type { JsonSchemaKeywords } from './types.ts' -export const JSON_SCHEMA_LOGIC_KEYWORDS: Set = /* @__PURE__ */ 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_LOGIC_KEYWORDS: Set = + /* @__PURE__ */ 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: Set = /* @__PURE__ */ new Set(['string', 'number', 'integer', 'boolean', 'null']) +export const JSON_SCHEMA_PRIMITIVE_TYPES: Set = /* @__PURE__ */ new Set([ + 'string', + 'number', + 'integer', + 'boolean', + 'null', +]) -export const JSON_SCHEMA_RECORD_KEYWORDS: Set = /* @__PURE__ */ new Set(['properties', 'patternProperties', 'dependentSchemas', 'dependencies', '$defs']) +export const JSON_SCHEMA_RECORD_KEYWORDS: Set = /* @__PURE__ */ 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 index 7cb9bfb138..00ac83dffd 100644 --- a/packages/json-schema/src/convert.test.ts +++ b/packages/json-schema/src/convert.test.ts @@ -1,7 +1,8 @@ -import type { JsonSchemaConverter } from './convert' import { type } from '@orpc/contract' -import z from 'zod' -import { DelegatingJsonSchemaConverter } from './convert' +import * as z from 'zod' + +import type { JsonSchemaConverter } from './convert.ts' +import { DelegatingJsonSchemaConverter } from './convert.ts' describe('delegatingJsonSchemaConverter', () => { it('uses the first matching custom converter', () => { @@ -34,7 +35,9 @@ describe('delegatingJsonSchemaConverter', () => { expect(converter.convert(undefined, 'input')).toEqual([{}, true]) expect(converter.convert(type(map), 'input')).toEqual([{}, true]) - expect(converter.convert({ '~standard': { vendor: 'custom', version: 1, validate } }, 'output')).toEqual([{}, true]) + expect( + converter.convert({ '~standard': { vendor: 'custom', version: 1, validate } }, 'output'), + ).toEqual([{}, true]) expect(converter.convert(z.string(), 'input')).toEqual([{}, true]) expect(map).not.toHaveBeenCalled() diff --git a/packages/json-schema/src/convert.ts b/packages/json-schema/src/convert.ts index 7d4ccac804..2d250f3ec4 100644 --- a/packages/json-schema/src/convert.ts +++ b/packages/json-schema/src/convert.ts @@ -1,5 +1,6 @@ import type { AnySchema } from '@orpc/contract' -import type { JsonSchema } from './types' + +import type { JsonSchema } from './types.ts' /** * The conversion direction: `input` targets the schema's input type, `output` targets its output type. @@ -23,15 +24,19 @@ export interface JsonSchemaConverter { /** * Converts an ORPC schema to a JSON Schema representation. */ - convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] + convert( + schema: AnySchema | undefined, + direction: JsonSchemaConverterDirection, + ): [jsonSchema: JsonSchema, optional: boolean] } export class DelegatingJsonSchemaConverter implements Pick { - constructor( - private readonly converters: JsonSchemaConverter[] = [], - ) {} + constructor(private readonly converters: JsonSchemaConverter[] = []) {} - convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] { + convert( + schema: AnySchema | undefined, + direction: JsonSchemaConverterDirection, + ): [jsonSchema: JsonSchema, optional: boolean] { for (const converter of this.converters) { if (converter.condition(schema, direction)) { return converter.convert(schema, direction) diff --git a/packages/json-schema/src/index.test.ts b/packages/json-schema/src/index.test.ts index 0350e1a873..213f48518e 100644 --- a/packages/json-schema/src/index.test.ts +++ b/packages/json-schema/src/index.test.ts @@ -1,5 +1,5 @@ it('exports utils, DelegatingJsonSchemaConverter, StandardJsonSchemaConverter, JsonSchemaCoercer, SmartCoercionHandlerPlugin, SmartCoercionLinkPlugin', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ isJsonObjectSchema: expect.any(Function), combineJsonSchemasWithComposition: expect.any(Function), ensureJsonSchemaObject: expect.any(Function), diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts index 7e040b8041..cbea55b703 100644 --- a/packages/json-schema/src/index.ts +++ b/packages/json-schema/src/index.ts @@ -1,11 +1,11 @@ -export * from './coercer' -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 * from './coercer.ts' +export * from './composition-utils.ts' +export * from './convert.ts' +export * from './ref-utils.ts' +export * from './smart-coercion-handler-plugin.ts' +export * from './smart-coercion-link-plugin.ts' +export * from './standard-json-schema-converter.ts' +export * from './types.ts' +export * from './utils.ts' 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 index 6d8e1989f0..5a647b2ee3 100644 --- a/packages/json-schema/src/ref-utils.test.ts +++ b/packages/json-schema/src/ref-utils.test.ts @@ -1,5 +1,13 @@ -import type { JsonSchema } from './types' -import { decodeJsonPointerSegment, encodeJsonPointerSegment, hoistRecursiveRefToDef, mapJsonSchemaRefs, resolveJsonSchemaLocalPointer, resolveJsonSchemaRootLocalRef, visitJsonSchemaRefs } from './ref-utils' +import { + decodeJsonPointerSegment, + encodeJsonPointerSegment, + hoistRecursiveRefToDef, + mapJsonSchemaRefs, + resolveJsonSchemaLocalPointer, + resolveJsonSchemaRootLocalRef, + visitJsonSchemaRefs, +} from './ref-utils.ts' +import type { JsonSchema } from './types.ts' describe('visitJsonSchemaRefs', () => { it('visits refs under the same keywords mapJsonSchemaRefs rewrites, and skips non-schema keywords', () => { @@ -13,6 +21,7 @@ describe('visitJsonSchemaRefs', () => { prefixItems: [{ $ref: '#/$defs/F' }], not: { $ref: '#/$defs/G' }, if: { $ref: '#/$defs/H' }, + // oxlint-disable-next-line unicorn/no-thenable then: { $ref: '#/$defs/I' }, else: { $ref: '#/$defs/J' }, items: { $ref: '#/$defs/K' }, @@ -29,9 +38,9 @@ describe('visitJsonSchemaRefs', () => { }) const visited: string[] = [] - visitJsonSchemaRefs(schema, ref => visited.push(ref)) + visitJsonSchemaRefs(schema, (ref) => visited.push(ref)) - expect(visited.sort()).toEqual(mapped.sort()) + expect(visited.toSorted()).toEqual(mapped.toSorted()) expect(visited).not.toContain('#/$defs/Ignored') expect(visited.length).toBe(14) }) @@ -42,22 +51,25 @@ describe('visitJsonSchemaRefs', () => { cyclic.items = cyclic const visited: string[] = [] - visitJsonSchemaRefs({ - type: 'object', - properties: { - a: shared, - b: shared, - c: cyclic as JsonSchema, + visitJsonSchemaRefs( + { + type: 'object', + properties: { + a: shared, + b: shared, + c: cyclic as JsonSchema, + }, }, - }, ref => visited.push(ref)) + (ref) => visited.push(ref), + ) expect(visited).toEqual(['#/$defs/Shared']) }) it('ignores non-object schemas', () => { const visited: string[] = [] - visitJsonSchemaRefs(true, ref => visited.push(ref)) - visitJsonSchemaRefs(false, ref => visited.push(ref)) + visitJsonSchemaRefs(true, (ref) => visited.push(ref)) + visitJsonSchemaRefs(false, (ref) => visited.push(ref)) expect(visited).toEqual([]) }) }) @@ -93,33 +105,28 @@ 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' }, + const schema = mapJsonSchemaRefs( + { + type: 'object', + properties: { + child: { $ref: '#/$defs/Node' }, + }, + anyOf: [{ $ref: '#/$defs/Leaf' }], + examples: [{ $ref: '#/$defs/Ignored' }], }, - anyOf: [ - { $ref: '#/$defs/Leaf' }, - ], - examples: [ - { $ref: '#/$defs/Ignored' }, - ], - }, (ref, path) => { - visited.push([ref, path]) - return ref - }) + (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' }, - ], + anyOf: [{ $ref: '#/$defs/Leaf' }], + examples: [{ $ref: '#/$defs/Ignored' }], }) expect(visited).toEqual([ @@ -131,27 +138,30 @@ describe('mapJsonSchemaRefs', () => { 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' }, + const rewritten = mapJsonSchemaRefs( + { + anyOf: [ + { $ref: '#/$defs/Node' }, + { + properties: { + child: { $ref: '#/$defs/Child' }, + }, + }, + ], + $defs: { + Node: { + items: { $ref: '#/$defs/Leaf' }, }, }, - ], - $defs: { - Node: { - items: { $ref: '#/$defs/Leaf' }, + dependentSchemas: { + feature: { $ref: '#/$defs/Feature' }, }, }, - dependentSchemas: { - feature: { $ref: '#/$defs/Feature' }, + (ref, path) => { + visited.push([ref, path]) + return `${ref}?visited=${path.join('.')}` }, - }, (ref, path) => { - visited.push([ref, path]) - return `${ref}?visited=${path.join('.')}` - }) + ) expect(rewritten).toEqual({ anyOf: [ @@ -183,64 +193,64 @@ describe('mapJsonSchemaRefs', () => { 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' }, - ], + 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-') - }) + (ref, path) => { + visited.push([ref, path]) + return ref.replace('#/$defs/', '#/$defs/rewritten-') + }, + ) expect(rewritten).toEqual({ type: 'object', title: 'Example', - examples: [ - { $ref: '#/$defs/Ignored' }, - ], + examples: [{ $ref: '#/$defs/Ignored' }], default: { $ref: '#/$defs/AlsoIgnored', }, properties: { nested: { - allOf: [ - { $ref: '#/$defs/rewritten-Tracked' }, - ], + allOf: [{ $ref: '#/$defs/rewritten-Tracked' }], }, }, }) - expect(visited).toEqual([ - ['#/$defs/Tracked', ['properties', 'nested', 'allOf', 0, '$ref']], - ]) + expect(visited).toEqual([['#/$defs/Tracked', ['properties', 'nested', 'allOf', 0, '$ref']]]) }) it('keeps properties and defs named __proto__ as own keys', () => { const rewritten = mapJsonSchemaRefs( - JSON.parse('{"properties":{"__proto__":{"$ref":"#/$defs/__proto__"}},"$defs":{"__proto__":{"type":"string"}}}'), - ref => ref.replace('#/$defs/', '#/$defs/rewritten-'), + JSON.parse( + '{"properties":{"__proto__":{"$ref":"#/$defs/__proto__"}},"$defs":{"__proto__":{"type":"string"}}}', + ), + (ref) => ref.replace('#/$defs/', '#/$defs/rewritten-'), ) - expect(JSON.stringify(rewritten)).toBe('{"properties":{"__proto__":{"$ref":"#/$defs/rewritten-__proto__"}},"$defs":{"__proto__":{"type":"string"}}}') + expect(JSON.stringify(rewritten)).toBe( + '{"properties":{"__proto__":{"$ref":"#/$defs/rewritten-__proto__"}},"$defs":{"__proto__":{"type":"string"}}}', + ) }) }) describe('resolveJsonSchemaRootLocalRef', () => { it('returns unsupported refs unchanged', () => { const schemaWithoutRef: JsonSchema = { type: 'string' } - const externalRef: JsonSchema = { $ref: '#/components/schemas/User', $defs: { User: { 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 } @@ -338,10 +348,15 @@ describe('resolveJsonSchemaRootLocalRef', () => { }) it('follows chained refs through the $defs arg', () => { - expect(resolveJsonSchemaRootLocalRef({ $ref: '#/$defs/outer' }, { - outer: { $ref: '#/$defs/inner', title: 'outer' }, - inner: { type: 'string' }, - })).toEqual({ type: 'string', title: 'outer' }) + expect( + resolveJsonSchemaRootLocalRef( + { $ref: '#/$defs/outer' }, + { + outer: { $ref: '#/$defs/inner', title: 'outer' }, + inner: { type: 'string' }, + }, + ), + ).toEqual({ type: 'string', title: 'outer' }) }) it('prefer $defs arg over schema.$defs even undefined', () => { @@ -352,9 +367,11 @@ describe('resolveJsonSchemaRootLocalRef', () => { }, } - expect(resolveJsonSchemaRootLocalRef(schema, { - branch: false, - })).toEqual(false) + expect( + resolveJsonSchemaRootLocalRef(schema, { + branch: false, + }), + ).toEqual(false) expect(resolveJsonSchemaRootLocalRef(schema, undefined)).toBe(schema) }) }) @@ -388,10 +405,7 @@ describe('hoistRecursiveRefToDef', () => { required: ['value', 'child'], $defs: { Leaf: { - anyOf: [ - { type: 'null' }, - { $ref: '#' }, - ], + anyOf: [{ type: 'null' }, { $ref: '#' }], }, }, } @@ -400,10 +414,7 @@ describe('hoistRecursiveRefToDef', () => { $ref: '#/$defs/__schema0', $defs: { Leaf: { - anyOf: [ - { type: 'null' }, - { $ref: '#/$defs/__schema0' }, - ], + anyOf: [{ type: 'null' }, { $ref: '#/$defs/__schema0' }], }, __schema0: { type: 'object', diff --git a/packages/json-schema/src/ref-utils.ts b/packages/json-schema/src/ref-utils.ts index 91343af351..9527677d7d 100644 --- a/packages/json-schema/src/ref-utils.ts +++ b/packages/json-schema/src/ref-utils.ts @@ -3,9 +3,10 @@ * and exclusively use absolute JSON pointers for `$ref` values. */ -import type { JsonSchema } from './types' import { get, setOwn } from '@orpc/shared' -import { JSON_SCHEMA_LOGIC_KEYWORDS, JSON_SCHEMA_RECORD_KEYWORDS } from './constants' + +import { JSON_SCHEMA_LOGIC_KEYWORDS, JSON_SCHEMA_RECORD_KEYWORDS } from './constants.ts' +import type { JsonSchema } from './types.ts' /** * Encodes a JSON Pointer segment according to RFC 6901. @@ -29,7 +30,10 @@ export function decodeJsonPointerSegment(segment: string): string { * Resolves a local JSON pointer ref (`#` or `#/...`) against the root schema. * Returns `undefined` for non-local refs and missing targets. */ -export function resolveJsonSchemaLocalPointer(root: JsonSchema, ref: string): JsonSchema | undefined { +export function resolveJsonSchemaLocalPointer( + root: JsonSchema, + ref: string, +): JsonSchema | undefined { if (ref === '#') { return root } @@ -68,11 +72,9 @@ export function visitJsonSchemaRefs( const val = value[key] if (key === '$ref' && typeof val === 'string') { visit(val) - } - else if (!schemaLevel) { + } else if (!schemaLevel) { visitJsonSchemaRefs(val as JsonSchema, visit, true, seen) - } - else if (JSON_SCHEMA_LOGIC_KEYWORDS.has(key) || JSON_SCHEMA_RECORD_KEYWORDS.has(key)) { + } else if (JSON_SCHEMA_LOGIC_KEYWORDS.has(key) || JSON_SCHEMA_RECORD_KEYWORDS.has(key)) { visitJsonSchemaRefs(val as JsonSchema, visit, !JSON_SCHEMA_RECORD_KEYWORDS.has(key), seen) } } @@ -89,7 +91,9 @@ export function mapJsonSchemaRefs( } if (Array.isArray(value)) { - return value.map((item, index) => mapJsonSchemaRefs(item, map, schemaLevel, [...path, index])) as any + return value.map((item, index) => + mapJsonSchemaRefs(item, map, schemaLevel, [...path, index]), + ) as any } const result: Record = {} @@ -97,14 +101,18 @@ export function mapJsonSchemaRefs( const val = value[key] if (key === '$ref' && typeof val === 'string') { setOwn(result, key, map(val, [...path, key])) - } - else if (!schemaLevel) { + } else if (!schemaLevel) { setOwn(result, key, mapJsonSchemaRefs(val as JsonSchema, map, true, [...path, key])) - } - else if (JSON_SCHEMA_LOGIC_KEYWORDS.has(key) || JSON_SCHEMA_RECORD_KEYWORDS.has(key)) { - setOwn(result, key, mapJsonSchemaRefs(val as JsonSchema, map, !JSON_SCHEMA_RECORD_KEYWORDS.has(key), [...path, key])) - } - else { + } else if (JSON_SCHEMA_LOGIC_KEYWORDS.has(key) || JSON_SCHEMA_RECORD_KEYWORDS.has(key)) { + setOwn( + result, + key, + mapJsonSchemaRefs(val as JsonSchema, map, !JSON_SCHEMA_RECORD_KEYWORDS.has(key), [ + ...path, + key, + ]), + ) + } else { setOwn(result, key, val) } } @@ -120,8 +128,9 @@ export function hoistRecursiveRefToDef(schema: JsonSchema): JsonSchema { return schema } - const isRootRef = (ref: string) => ref === '#' - || (!ref.startsWith('#/$defs/') && resolveJsonSchemaLocalPointer(schema, ref) !== undefined) + const isRootRef = (ref: string) => + ref === '#' || + (!ref.startsWith('#/$defs/') && resolveJsonSchemaLocalPointer(schema, ref) !== undefined) // most schemas have no root ref, so detect one without copying the schema let hasRootRef = false @@ -135,7 +144,9 @@ export function hoistRecursiveRefToDef(schema: JsonSchema): JsonSchema { const defName = findRecursiveJsonSchemaDefName(schema.$defs) const defRef = `#/$defs/${encodeJsonPointerSegment(defName)}` - const { $defs, ...rest } = mapJsonSchemaRefs(schema, ref => isRootRef(ref) ? `${defRef}${ref.slice(1)}` : ref) as Exclude + const { $defs, ...rest } = mapJsonSchemaRefs(schema, (ref) => + isRootRef(ref) ? `${defRef}${ref.slice(1)}` : ref, + ) as Exclude return { $ref: defRef, @@ -181,10 +192,17 @@ export function resolveJsonSchemaRootLocalRef( const followedRefs = new Set() let current = schema - while (typeof current.$ref === 'string' && current.$ref.startsWith('#/$defs/') && !followedRefs.has(current.$ref)) { + while ( + typeof current.$ref === 'string' && + current.$ref.startsWith('#/$defs/') && + !followedRefs.has(current.$ref) + ) { followedRefs.add(current.$ref) - const resolved = get($defs, current.$ref.slice('#/$defs/'.length).split('/').map(decodeJsonPointerSegment)) as JsonSchema | undefined + const resolved = get( + $defs, + current.$ref.slice('#/$defs/'.length).split('/').map(decodeJsonPointerSegment), + ) as JsonSchema | undefined if (resolved === undefined) { return current @@ -204,7 +222,9 @@ export function resolveJsonSchemaRootLocalRef( return current } -function findRecursiveJsonSchemaDefName(defs: Exclude['$defs'] | undefined): string { +function findRecursiveJsonSchemaDefName( + defs: Exclude['$defs'] | undefined, +): string { let index = 0 while (defs?.[`__schema${index}`] !== undefined) { diff --git a/packages/json-schema/src/smart-coercion-handler-plugin.test.ts b/packages/json-schema/src/smart-coercion-handler-plugin.test.ts index 003b54dd68..da4554ee6b 100644 --- a/packages/json-schema/src/smart-coercion-handler-plugin.test.ts +++ b/packages/json-schema/src/smart-coercion-handler-plugin.test.ts @@ -1,6 +1,7 @@ import { oc } from '@orpc/contract' -import z from 'zod' -import { SmartCoercionHandlerPlugin } from './smart-coercion-handler-plugin' +import * as z from 'zod' + +import { SmartCoercionHandlerPlugin } from './smart-coercion-handler-plugin.ts' describe('smartCoercionHandlerPlugin', () => { it('prepends its interceptor and skips coercion when input schemas are missing', async () => { @@ -14,11 +15,13 @@ describe('smartCoercionHandlerPlugin', () => { const next = vi.fn().mockResolvedValue('handled') - await expect(options.clientInterceptors?.[0]?.({ - procedure: oc, - input: { value: '1' }, - next, - } as any)).resolves.toBe('handled') + await expect( + options.clientInterceptors?.[0]?.({ + procedure: oc, + input: { value: '1' }, + next, + } as any), + ).resolves.toBe('handled') expect(next).toHaveBeenCalledOnce() expect(next).toHaveBeenCalledWith() @@ -35,17 +38,21 @@ describe('smartCoercionHandlerPlugin', () => { 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') + 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 } }) @@ -53,7 +60,8 @@ describe('smartCoercionHandlerPlugin', () => { it('passes maxStepsPerValue to the coercer', async () => { const procedure = oc.input(z.object({ page: z.number() })) - const interceptor = new SmartCoercionHandlerPlugin({ maxStepsPerValue: 0 }).init({} as any).clientInterceptors?.[0] + const interceptor = new SmartCoercionHandlerPlugin({ maxStepsPerValue: 0 }).init({} as any) + .clientInterceptors?.[0] const next = vi.fn() await interceptor?.({ procedure, input: { page: '1' }, next } as any) @@ -63,10 +71,15 @@ describe('smartCoercionHandlerPlugin', () => { it('passes maxBigIntDigits to the coercer', async () => { const plugin = new SmartCoercionHandlerPlugin({ - converters: [{ - condition: () => true, - convert: () => [{ 'type': 'string', 'pattern': '^-?[0-9]+$', 'x-native-type': 'bigint' } as any, false], - }], + converters: [ + { + condition: () => true, + convert: () => [ + { 'type': 'string', 'pattern': '^-?[0-9]+$', 'x-native-type': 'bigint' } as any, + false, + ], + }, + ], maxBigIntDigits: 3, }) const procedure = oc.input(z.unknown()) diff --git a/packages/json-schema/src/smart-coercion-handler-plugin.ts b/packages/json-schema/src/smart-coercion-handler-plugin.ts index 79a5bcca93..6960376a29 100644 --- a/packages/json-schema/src/smart-coercion-handler-plugin.ts +++ b/packages/json-schema/src/smart-coercion-handler-plugin.ts @@ -1,13 +1,14 @@ import type { AnySchema } from '@orpc/contract' import type { Context } from '@orpc/server' import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' -import type { JsonSchemaCoercerOptions } from './coercer' -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' + +import type { JsonSchemaCoercerOptions } from './coercer.ts' +import { JsonSchemaCoercer } from './coercer.ts' +import type { JsonSchemaConverter } from './convert.ts' +import { DelegatingJsonSchemaConverter } from './convert.ts' +import { StandardJsonSchemaConverter } from './standard-json-schema-converter.ts' +import type { JsonSchema } from './types.ts' export interface SmartCoercionHandlerPluginOptions extends JsonSchemaCoercerOptions { converters?: JsonSchemaConverter[] | undefined diff --git a/packages/json-schema/src/smart-coercion-link-plugin.test.ts b/packages/json-schema/src/smart-coercion-link-plugin.test.ts index 79ce2b8d57..c0a41e5daf 100644 --- a/packages/json-schema/src/smart-coercion-link-plugin.test.ts +++ b/packages/json-schema/src/smart-coercion-link-plugin.test.ts @@ -1,8 +1,9 @@ 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' +import * as z from 'zod' + +import { SmartCoercionLinkPlugin } from './smart-coercion-link-plugin.ts' describe('smartCoercionLinkPlugin', () => { beforeEach(() => { @@ -23,12 +24,12 @@ describe('smartCoercionLinkPlugin', () => { 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"', - ) + 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 () => { @@ -44,10 +45,12 @@ describe('smartCoercionLinkPlugin', () => { 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) + await expect( + interceptor?.({ + path: ['users', 'get'], + next, + } as any), + ).resolves.toBe(output) expect(next).toHaveBeenCalledOnce() }) @@ -55,33 +58,44 @@ describe('smartCoercionLinkPlugin', () => { 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() })), + 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() + 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 }) + 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('passes maxStepsPerValue to the coercer', async () => { const contract = oc.router({ get: oc.output(z.number()) }) - const interceptor = new SmartCoercionLinkPlugin(contract, { maxStepsPerValue: 0 }).init({} as any).interceptors?.[0] + const interceptor = new SmartCoercionLinkPlugin(contract, { maxStepsPerValue: 0 }).init( + {} as any, + ).interceptors?.[0] - await expect(interceptor?.({ path: ['get'], next: vi.fn().mockResolvedValue('1') } as any)).resolves.toBe('1') + await expect( + interceptor?.({ path: ['get'], next: vi.fn().mockResolvedValue('1') } as any), + ).resolves.toBe('1') }) it('throw the original error when it not defined ORPCError or has no data schema', async () => { @@ -99,25 +113,32 @@ describe('smartCoercionLinkPlugin', () => { ;(definedORPCError as any).defined = true const interceptor = plugin.init({} as any).interceptors?.[0] - const next = vi.fn() + 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) + 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 () => { @@ -139,10 +160,12 @@ describe('smartCoercionLinkPlugin', () => { 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) => { + 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) @@ -158,15 +181,24 @@ describe('smartCoercionLinkPlugin', () => { get: oc.output(z.unknown()), }) const plugin = new SmartCoercionLinkPlugin(contract, { - converters: [{ - condition: () => true, - convert: () => [{ 'type': 'string', 'pattern': '^-?[0-9]+$', 'x-native-type': 'bigint' } as any, false], - }], + converters: [ + { + condition: () => true, + convert: () => [ + { 'type': 'string', 'pattern': '^-?[0-9]+$', 'x-native-type': 'bigint' } as any, + false, + ], + }, + ], maxBigIntDigits: 3, }) const interceptor = plugin.init({} as any).interceptors?.[0] - await expect(interceptor?.({ path: ['get'], next: vi.fn().mockResolvedValue('999') } as any)).resolves.toBe(999n) - await expect(interceptor?.({ path: ['get'], next: vi.fn().mockResolvedValue('1000') } as any)).resolves.toBe('1000') + await expect( + interceptor?.({ path: ['get'], next: vi.fn().mockResolvedValue('999') } as any), + ).resolves.toBe(999n) + await expect( + interceptor?.({ path: ['get'], next: vi.fn().mockResolvedValue('1000') } as any), + ).resolves.toBe('1000') }) }) diff --git a/packages/json-schema/src/smart-coercion-link-plugin.ts b/packages/json-schema/src/smart-coercion-link-plugin.ts index 07ffe6fd94..f19942a6e4 100644 --- a/packages/json-schema/src/smart-coercion-link-plugin.ts +++ b/packages/json-schema/src/smart-coercion-link-plugin.ts @@ -1,15 +1,16 @@ import type { ClientContext } from '@orpc/client' +import { cloneORPCError, ORPCError } from '@orpc/client' import type { StandardLinkOptions, StandardLinkPlugin } from '@orpc/client/standard' import type { AnySchema, ErrorMap, RouterContract } from '@orpc/contract' -import type { JsonSchemaCoercerOptions } from './coercer' -import type { JsonSchemaConverter } from './convert' -import type { JsonSchema } from './types' -import { cloneORPCError, ORPCError } from '@orpc/client' import { getProcedureContractOrThrow } from '@orpc/contract' import { getOwn, toArray } from '@orpc/shared' -import { JsonSchemaCoercer } from './coercer' -import { DelegatingJsonSchemaConverter } from './convert' -import { StandardJsonSchemaConverter } from './standard-json-schema-converter' + +import type { JsonSchemaCoercerOptions } from './coercer.ts' +import { JsonSchemaCoercer } from './coercer.ts' +import type { JsonSchemaConverter } from './convert.ts' +import { DelegatingJsonSchemaConverter } from './convert.ts' +import { StandardJsonSchemaConverter } from './standard-json-schema-converter.ts' +import type { JsonSchema } from './types.ts' export interface SmartCoercionLinkPluginOptions extends JsonSchemaCoercerOptions { converters?: JsonSchemaConverter[] | undefined @@ -62,8 +63,7 @@ export class SmartCoercionLinkPlugin implements Standar const coercedOutput = this.coerceValue(outputSchemas, output) return coercedOutput - } - catch (error) { + } catch (error) { if (!(error instanceof ORPCError) || !error.defined) { throw error } diff --git a/packages/json-schema/src/standard-json-schema-converter.test.ts b/packages/json-schema/src/standard-json-schema-converter.test.ts index 0b853b2251..c860cf5018 100644 --- a/packages/json-schema/src/standard-json-schema-converter.test.ts +++ b/packages/json-schema/src/standard-json-schema-converter.test.ts @@ -1,9 +1,16 @@ import type { AnySchema } from '@orpc/contract' import * as arktype from 'arktype' -import z from 'zod' -import { isStandardSchemaOptional, StandardJsonSchemaConverter } from './standard-json-schema-converter' +import * as z from 'zod' -function withStandardOverrides(schema: TSchema, overrides: Record): TSchema { +import { + isStandardSchemaOptional, + StandardJsonSchemaConverter, +} from './standard-json-schema-converter.ts' + +function withStandardOverrides( + schema: TSchema, + overrides: Record, +): TSchema { Object.defineProperty(schema, '~standard', { value: { ...schema['~standard'], @@ -29,17 +36,20 @@ describe('standardJsonSchemaConverter', () => { 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, - ]) - }) + ] 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([ @@ -85,7 +95,9 @@ describe('standardJsonSchemaConverter', () => { ]) }) - it('does not leak a rejection when async standard validation fails on undefined', async ({ onTestFinished }) => { + it('does not leak a rejection when async standard validation fails on undefined', async ({ + onTestFinished, + }) => { const unhandledRejectionHandler = vi.fn() process.on('unhandledRejection', unhandledRejectionHandler) @@ -102,7 +114,7 @@ describe('standardJsonSchemaConverter', () => { false, ]) - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(unhandledRejectionHandler).not.toHaveBeenCalled() }) @@ -147,7 +159,9 @@ describe('isStandardSchemaOptional', () => { expect(isStandardSchemaOptional(asyncSchema, 'input')).toBe(false) }) - it('does not leak a rejection when async standard validation fails', async ({ onTestFinished }) => { + it('does not leak a rejection when async standard validation fails', async ({ + onTestFinished, + }) => { const unhandledRejectionHandler = vi.fn() process.on('unhandledRejection', unhandledRejectionHandler) @@ -161,7 +175,7 @@ describe('isStandardSchemaOptional', () => { expect(isStandardSchemaOptional(schema, 'input')).toBe(false) - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(unhandledRejectionHandler).not.toHaveBeenCalled() }) }) diff --git a/packages/json-schema/src/standard-json-schema-converter.ts b/packages/json-schema/src/standard-json-schema-converter.ts index c92f653e57..05bae1c700 100644 --- a/packages/json-schema/src/standard-json-schema-converter.ts +++ b/packages/json-schema/src/standard-json-schema-converter.ts @@ -1,9 +1,10 @@ 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' +// oxlint-disable-next-line no-restricted-imports +import type { StandardJSONSchemaV1 } from '@standard-schema/spec' + +import type { JsonSchemaConverter, JsonSchemaConverterDirection } from './convert.ts' +import type { JsonSchema } from './types.ts' /** * Returns true when the schema accepts `undefined` in the given direction, @@ -11,7 +12,10 @@ import { isTypescriptObject } from '@orpc/shared' * * Returns false when validation throws or is async. An async result is never left as an unhandled rejection. */ -export function isStandardSchemaOptional(schema: AnySchema, direction: JsonSchemaConverterDirection): boolean { +export function isStandardSchemaOptional( + schema: AnySchema, + direction: JsonSchemaConverterDirection, +): boolean { try { const result = schema['~standard'].validate(undefined) @@ -21,8 +25,7 @@ export function isStandardSchemaOptional(schema: AnySchema, direction: JsonSchem } return !result.issues && (direction === 'input' || result.value === undefined) - } - catch { + } catch { return false } } @@ -30,29 +33,35 @@ export function isStandardSchemaOptional(schema: AnySchema, direction: JsonSchem 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', + 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] { + 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] { + 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' }) + const jsonSchema = + direction === 'input' + ? schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) + : schema['~standard'].jsonSchema.output({ target: 'draft-2020-12' }) return [jsonSchema, isStandardSchemaOptional(schema, direction)] - } - catch { + } catch { return [{}, true] } } diff --git a/packages/json-schema/src/types.ts b/packages/json-schema/src/types.ts index 63ddb667d4..4a2ae9866e 100644 --- a/packages/json-schema/src/types.ts +++ b/packages/json-schema/src/types.ts @@ -1,4 +1,4 @@ -// eslint-disable-next-line no-restricted-imports +// oxlint-disable-next-line no-restricted-imports import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' /** diff --git a/packages/json-schema/src/utils.test.ts b/packages/json-schema/src/utils.test.ts index 74e91f9941..d9d109dee7 100644 --- a/packages/json-schema/src/utils.test.ts +++ b/packages/json-schema/src/utils.test.ts @@ -1,5 +1,13 @@ -import type { JsonSchema } from './types' -import { containsJsonFileSchema, ensureJsonSchemaObject, isJsonArraySchema, isJsonFileSchema, isJsonObjectSchema, isJsonPrimitiveSchema, isUnconstrainedSchema } from './utils' +import type { JsonSchema } from './types.ts' +import { + containsJsonFileSchema, + ensureJsonSchemaObject, + isJsonArraySchema, + isJsonFileSchema, + isJsonObjectSchema, + isJsonPrimitiveSchema, + isUnconstrainedSchema, +} from './utils.ts' it('isJsonPrimitiveSchema', () => { expect(isJsonPrimitiveSchema({ type: 'string' })).toBe(true) @@ -7,11 +15,22 @@ it('isJsonPrimitiveSchema', () => { expect(isJsonPrimitiveSchema({ enum: ['a', 'b'] })).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) + expect(isJsonPrimitiveSchema({ type: 'object', properties: { a: { type: 'string' } } })).toBe( + false, + ) + expect( + isJsonPrimitiveSchema({ + anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }), + ).toBe(false) // does not support union - need manually flattenJsonUnionSchema.every - expect(isJsonPrimitiveSchema({ description: 'primitive union', anyOf: [{ type: 'number' }, { oneOf: [{ type: 'boolean' }, { const: 'x' }] }] })).toBe(false) + expect( + isJsonPrimitiveSchema({ + description: 'primitive union', + anyOf: [{ type: 'number' }, { oneOf: [{ type: 'boolean' }, { const: 'x' }] }], + }), + ).toBe(false) }) it('isJsonFileSchema', () => { @@ -29,11 +48,28 @@ it('isJsonFileSchema', () => { it('containsJsonFileSchema', () => { expect(containsJsonFileSchema({ type: 'string', contentMediaType: 'image/png' })).toBe(true) - expect(containsJsonFileSchema({ type: 'object', properties: { avatar: { type: 'string', format: 'binary' } } })).toBe(true) - expect(containsJsonFileSchema({ type: 'array', items: { anyOf: [{ type: 'number' }, { type: 'string', contentEncoding: 'binary' }] } })).toBe(true) - expect(containsJsonFileSchema({ $ref: '#/$defs/File', $defs: { File: { type: 'string', contentMediaType: '*/*' } } })).toBe(true) - - expect(containsJsonFileSchema({ type: 'object', properties: { name: { type: 'string' } } })).toBe(false) + expect( + containsJsonFileSchema({ + type: 'object', + properties: { avatar: { type: 'string', format: 'binary' } }, + }), + ).toBe(true) + expect( + containsJsonFileSchema({ + type: 'array', + items: { anyOf: [{ type: 'number' }, { type: 'string', contentEncoding: 'binary' }] }, + }), + ).toBe(true) + expect( + containsJsonFileSchema({ + $ref: '#/$defs/File', + $defs: { File: { type: 'string', contentMediaType: '*/*' } }, + }), + ).toBe(true) + + expect(containsJsonFileSchema({ type: 'object', properties: { name: { type: 'string' } } })).toBe( + false, + ) expect(containsJsonFileSchema(true)).toBe(false) }) @@ -41,23 +77,63 @@ it('containsJsonFileSchema follows only reachable refs', () => { const $defs: Record = { File: { type: 'string', contentMediaType: '*/*' }, WithFile: { type: 'object', properties: { file: { $ref: '#/$defs/File' } } }, - Tree: { type: 'object', properties: { name: { type: 'string' }, children: { type: 'array', items: { $ref: '#/$defs/Tree' } } } }, - TreeWithFile: { type: 'object', properties: { file: { $ref: '#/$defs/File' }, children: { type: 'array', items: { $ref: '#/$defs/TreeWithFile' } } } }, + Tree: { + type: 'object', + properties: { + name: { type: 'string' }, + children: { type: 'array', items: { $ref: '#/$defs/Tree' } }, + }, + }, + TreeWithFile: { + type: 'object', + properties: { + file: { $ref: '#/$defs/File' }, + children: { type: 'array', items: { $ref: '#/$defs/TreeWithFile' } }, + }, + }, } - expect(containsJsonFileSchema({ type: 'object', properties: { id: { type: 'string' } }, $defs })).toBe(false) + expect( + containsJsonFileSchema({ type: 'object', properties: { id: { type: 'string' } }, $defs }), + ).toBe(false) expect(containsJsonFileSchema({ $ref: '#/$defs/Tree', $defs })).toBe(false) - expect(containsJsonFileSchema({ type: 'object', properties: { nested: { $ref: '#/$defs/WithFile' } }, $defs })).toBe(true) - expect(containsJsonFileSchema({ anyOf: [{ $ref: '#/$defs/Tree' }, { $ref: '#/$defs/TreeWithFile' }], $defs })).toBe(true) - expect(containsJsonFileSchema({ type: 'object', properties: { file: { $ref: '#/$defs/WithFile/properties/file' } }, $defs })).toBe(true) - - expect(containsJsonFileSchema({ type: 'object', properties: { self: { $ref: '#' } } })).toBe(false) - expect(containsJsonFileSchema({ type: 'object', properties: { self: { $ref: '#' }, file: { type: 'string', format: 'binary' } } })).toBe(true) + expect( + containsJsonFileSchema({ + type: 'object', + properties: { nested: { $ref: '#/$defs/WithFile' } }, + $defs, + }), + ).toBe(true) + expect( + containsJsonFileSchema({ + anyOf: [{ $ref: '#/$defs/Tree' }, { $ref: '#/$defs/TreeWithFile' }], + $defs, + }), + ).toBe(true) + expect( + containsJsonFileSchema({ + type: 'object', + properties: { file: { $ref: '#/$defs/WithFile/properties/file' } }, + $defs, + }), + ).toBe(true) + + expect(containsJsonFileSchema({ type: 'object', properties: { self: { $ref: '#' } } })).toBe( + false, + ) + expect( + containsJsonFileSchema({ + type: 'object', + properties: { self: { $ref: '#' }, file: { type: 'string', format: 'binary' } }, + }), + ).toBe(true) expect(containsJsonFileSchema({ $ref: '#/$defs/Missing', $defs })).toBe(false) - expect(containsJsonFileSchema({ type: 'object', default: { type: 'string', format: 'binary' } })).toBe(false) + expect( + containsJsonFileSchema({ type: 'object', default: { type: 'string', format: 'binary' } }), + ).toBe(false) }) it('isJsonObjectSchema', () => { diff --git a/packages/json-schema/src/utils.ts b/packages/json-schema/src/utils.ts index 514659728d..5fc51e5b69 100644 --- a/packages/json-schema/src/utils.ts +++ b/packages/json-schema/src/utils.ts @@ -3,9 +3,13 @@ * and exclusively use absolute JSON pointers for `$ref` values. */ -import type { JsonSchema } from './types' -import { JSON_SCHEMA_LOGIC_KEYWORDS, JSON_SCHEMA_PRIMITIVE_TYPES, JSON_SCHEMA_RECORD_KEYWORDS } from './constants' -import { resolveJsonSchemaLocalPointer } from './ref-utils' +import { + JSON_SCHEMA_LOGIC_KEYWORDS, + JSON_SCHEMA_PRIMITIVE_TYPES, + JSON_SCHEMA_RECORD_KEYWORDS, +} from './constants.ts' +import { resolveJsonSchemaLocalPointer } from './ref-utils.ts' +import type { JsonSchema } from './types.ts' export function isJsonPrimitiveSchema(schema: JsonSchema): schema is JsonSchema & object { if (typeof schema === 'boolean') { @@ -27,13 +31,19 @@ export function isJsonPrimitiveSchema(schema: JsonSchema): schema is JsonSchema return false } -export type JsonFileSchema = JsonSchema & object & { type: 'string', contentMediaType?: string } +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') + return ( + typeof schema !== 'boolean' && + schema.type === 'string' && + (typeof schema.contentMediaType === 'string' || + schema.format === 'binary' || + schema.contentEncoding === 'binary') + ) } /** @@ -50,7 +60,7 @@ export function containsJsonFileSchema(schema: JsonSchema): boolean { seen.add(value) if (Array.isArray(value)) { - return value.some(item => visit(item, schemaLevel)) + return value.some((item) => visit(item, schemaLevel)) } const record = value as Record @@ -70,9 +80,11 @@ export function containsJsonFileSchema(schema: JsonSchema): boolean { return visit(val, true) } - return key !== '$defs' - && (JSON_SCHEMA_LOGIC_KEYWORDS.has(key) || JSON_SCHEMA_RECORD_KEYWORDS.has(key)) - && visit(val, !JSON_SCHEMA_RECORD_KEYWORDS.has(key)) + return ( + key !== '$defs' && + (JSON_SCHEMA_LOGIC_KEYWORDS.has(key) || JSON_SCHEMA_RECORD_KEYWORDS.has(key)) && + visit(val, !JSON_SCHEMA_RECORD_KEYWORDS.has(key)) + ) }) } @@ -105,7 +117,7 @@ export function isUnconstrainedSchema(schema: JsonSchema): boolean { return schema } - if (Object.keys(schema).every(k => !JSON_SCHEMA_LOGIC_KEYWORDS.has(k))) { + if (Object.keys(schema).every((k) => !JSON_SCHEMA_LOGIC_KEYWORDS.has(k))) { return true } diff --git a/packages/json-schema/tsconfig.json b/packages/json-schema/tsconfig.json index f190fca216..9979a0715c 100644 --- a/packages/json-schema/tsconfig.json +++ b/packages/json-schema/tsconfig.json @@ -1,17 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../shared" }, - { "path": "../server" }, - { "path": "../client" }, - { "path": "../contract" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/lock/README.md b/packages/lock/README.md index aeb749640c..adc027d36a 100644 --- a/packages/lock/README.md +++ b/packages/lock/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/lock/build.config.ts b/packages/lock/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/lock/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/lock/package.json b/packages/lock/package.json index bf0c2af026..b9a6557e12 100644 --- a/packages/lock/package.json +++ b/packages/lock/package.json @@ -1,75 +1,82 @@ { "name": "@orpc/experimental-lock", - "type": "module", "version": "2.0.0-beta.48", "description": "Locking for oRPC procedures, with memory, Redis, and Upstash adapters", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "api", + "lock", + "locking", + "middleware", + "mutex", + "orpc", + "redis", + "typescript", + "upstash" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/lock" }, - "keywords": [ - "orpc", - "lock", - "locking", - "mutex", - "redis", - "upstash", - "middleware", - "api", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./base-redis": "./src/adapters/base-redis.ts", + "./memory": "./src/adapters/memory.ts", + "./redis": "./src/adapters/redis.ts", + "./upstash": "./src/adapters/upstash.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./base-redis": { - "types": "./dist/adapters/base-redis.d.mts", - "import": "./dist/adapters/base-redis.mjs", - "default": "./dist/adapters/base-redis.mjs" + "types": "./dist/adapters/base-redis.d.ts", + "import": "./dist/adapters/base-redis.js", + "default": "./dist/adapters/base-redis.js" }, "./memory": { - "types": "./dist/adapters/memory.d.mts", - "import": "./dist/adapters/memory.mjs", - "default": "./dist/adapters/memory.mjs" + "types": "./dist/adapters/memory.d.ts", + "import": "./dist/adapters/memory.js", + "default": "./dist/adapters/memory.js" }, "./redis": { - "types": "./dist/adapters/redis.d.mts", - "import": "./dist/adapters/redis.mjs", - "default": "./dist/adapters/redis.mjs" + "types": "./dist/adapters/redis.d.ts", + "import": "./dist/adapters/redis.js", + "default": "./dist/adapters/redis.js" }, "./upstash": { - "types": "./dist/adapters/upstash.d.mts", - "import": "./dist/adapters/upstash.mjs", - "default": "./dist/adapters/upstash.mjs" + "types": "./dist/adapters/upstash.d.ts", + "import": "./dist/adapters/upstash.js", + "default": "./dist/adapters/upstash.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts", - "./base-redis": "./src/adapters/base-redis.ts", - "./memory": "./src/adapters/memory.ts", - "./redis": "./src/adapters/redis.ts", - "./upstash": "./src/adapters/upstash.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" + "prepack": "tsc" + }, + "dependencies": { + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*" + }, + "devDependencies": { + "@upstash/redis": "^1.41.0", + "redis": "^6.3.1" }, "peerDependencies": { "@upstash/redis": ">=1.35.6", @@ -82,13 +89,5 @@ "redis": { "optional": true } - }, - "dependencies": { - "@orpc/server": "workspace:*", - "@orpc/shared": "workspace:*" - }, - "devDependencies": { - "@upstash/redis": "^1.41.0", - "redis": "^6.3.1" } } diff --git a/packages/lock/src/adapters/base-redis.ts b/packages/lock/src/adapters/base-redis.ts index c26391dda0..e041844012 100644 --- a/packages/lock/src/adapters/base-redis.ts +++ b/packages/lock/src/adapters/base-redis.ts @@ -1,7 +1,8 @@ import type { Promisable } from '@orpc/shared' -import type { LockCallbackOptions, Locker, LockOptions } from '../types' import { sleep, throwIfAborted } from '@orpc/shared' -import { LockTimeoutError } from '../error' + +import { LockTimeoutError } from '../error.ts' +import type { LockCallbackOptions, Locker, LockOptions } from '../types.ts' const RELEASE_LOCK_SCRIPT = ` if redis.call('GET', KEYS[1]) == ARGV[1] then @@ -82,7 +83,11 @@ export abstract class BaseRedisLocker implements Locker { */ protected abstract evalScript(script: string, keys: string[], args: string[]): Promise - async lock(key: string, fn: (options: LockCallbackOptions) => Promisable, options: LockOptions = {}): Promise { + async lock( + key: string, + fn: (options: LockCallbackOptions) => Promisable, + options: LockOptions = {}, + ): Promise { const prefixedKey = `${this.keyPrefix}${key}` const ttl = options.ttl ?? this.ttl const deadline = Date.now() + (options.timeout ?? this.timeout) @@ -104,12 +109,10 @@ export abstract class BaseRedisLocker implements Locker { try { return await fn({ waited }) - } - finally { + } finally { try { await this.evalScript(RELEASE_LOCK_SCRIPT, [prefixedKey], [token]) - } - catch { + } catch { // the lock still expires through its TTL } } diff --git a/packages/lock/src/adapters/memory.test.ts b/packages/lock/src/adapters/memory.test.ts index bbe1ada3d9..f593114b9e 100644 --- a/packages/lock/src/adapters/memory.test.ts +++ b/packages/lock/src/adapters/memory.test.ts @@ -1,6 +1,7 @@ import { promiseWithResolvers, sleep } from '@orpc/shared' -import { LockTimeoutError } from '../error' -import { MemoryLocker } from './memory' + +import { LockTimeoutError } from '../error.ts' +import { MemoryLocker } from './memory.ts' describe('memoryLocker', () => { it('runs the callback right away when the lock is free and releases it afterwards', async () => { @@ -58,9 +59,11 @@ describe('memoryLocker', () => { it('releases the lock when the callback throws', async () => { const locker = new MemoryLocker() - await expect(locker.lock('key', () => { - throw new Error('boom') - })).rejects.toThrow('boom') + await expect( + locker.lock('key', () => { + throw new Error('boom') + }), + ).rejects.toThrow('boom') await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') }) @@ -71,14 +74,18 @@ describe('memoryLocker', () => { let maxActive = 0 let count = 0 - await Promise.all(Array.from({ length: 50 }, () => locker.lock('key', async () => { - active++ - maxActive = Math.max(maxActive, active) - const current = count - await sleep(1) - count = current + 1 - active-- - }))) + await Promise.all( + Array.from({ length: 50 }, () => + locker.lock('key', async () => { + active++ + maxActive = Math.max(maxActive, active) + const current = count + await sleep(1) + count = current + 1 + active-- + }), + ), + ) expect(maxActive).toBe(1) expect(count).toBe(50) @@ -283,11 +290,17 @@ describe('memoryLocker', () => { const locker = new MemoryLocker() const controller = new AbortController() - await expect(locker.lock('key', async () => { - controller.abort() - await sleep(1) - return 'ok' - }, { signal: controller.signal })).resolves.toBe('ok') + await expect( + locker.lock( + 'key', + async () => { + controller.abort() + await sleep(1) + return 'ok' + }, + { signal: controller.signal }, + ), + ).resolves.toBe('ok') }) }) }) diff --git a/packages/lock/src/adapters/memory.ts b/packages/lock/src/adapters/memory.ts index a11443d7d3..8a05f9c718 100644 --- a/packages/lock/src/adapters/memory.ts +++ b/packages/lock/src/adapters/memory.ts @@ -1,7 +1,8 @@ import type { Promisable } from '@orpc/shared' -import type { LockCallbackOptions, Locker, LockOptions } from '../types' import { promiseWithResolvers, throwIfAborted } from '@orpc/shared' -import { LockTimeoutError } from '../error' + +import { LockTimeoutError } from '../error.ts' +import type { LockCallbackOptions, Locker, LockOptions } from '../types.ts' export interface MemoryLockerOptions { /** @@ -50,7 +51,11 @@ export class MemoryLocker implements Locker { this.timeout = options.timeout ?? 10_000 } - async lock(key: string, fn: (options: LockCallbackOptions) => Promisable, options: LockOptions = {}): Promise { + async lock( + key: string, + fn: (options: LockCallbackOptions) => Promisable, + options: LockOptions = {}, + ): Promise { const ttl = options.ttl ?? this.ttl const timeout = options.timeout ?? this.timeout const token = {} @@ -62,8 +67,7 @@ export class MemoryLocker implements Locker { if (entry) { await this.wait(key, entry, token, timeout, options.signal) - } - else { + } else { entry = { holder: token, waiters: new Set() } this.entries.set(key, entry) } @@ -74,13 +78,18 @@ export class MemoryLocker implements Locker { try { return await fn({ waited }) - } - finally { + } finally { this.release(key, token) } } - private wait(key: string, entry: MemoryLockEntry, token: object, timeout: number, signal: AbortSignal | undefined): Promise { + private wait( + key: string, + entry: MemoryLockEntry, + token: object, + timeout: number, + signal: AbortSignal | undefined, + ): Promise { if (timeout <= 0) { return Promise.reject(new LockTimeoutError(key)) } diff --git a/packages/lock/src/adapters/redis.test.ts b/packages/lock/src/adapters/redis.test.ts index 8c669c9d31..a0567a500f 100644 --- a/packages/lock/src/adapters/redis.test.ts +++ b/packages/lock/src/adapters/redis.test.ts @@ -1,202 +1,232 @@ -import type { Locker, LockOptions } from '../types' import { promiseWithResolvers, sleep } from '@orpc/shared' import { createClient } from 'redis' -import { LockTimeoutError } from '../error' -import { RedisLocker } from './redis' + +import { LockTimeoutError } from '../error.ts' +import type { Locker, LockOptions } from '../types.ts' +import { RedisLocker } from './redis.ts' const REDIS_URL = process.env.REDIS_URL -describe.concurrent('redis locker integration', { - skip: !REDIS_URL, - timeout: 20_000, -}, () => { - const redis = createClient({ - url: REDIS_URL, - }) - - beforeAll(async () => { - await redis.connect() - }) - - function createTestingLocker( - { useRedis = redis, ...options }: Partial[1]> & { useRedis?: typeof redis } = {}, - ) { - const prefix = options.prefix ?? `orpc-redis-locker-${crypto.randomUUID()}:` - - return { - prefix, - locker: new RedisLocker(useRedis, { - ttl: 10_000, - retryInterval: 10, - ...options, +describe.concurrent( + 'redis locker integration', + { + skip: !REDIS_URL, + timeout: 20_000, + }, + () => { + const redis = createClient({ + url: REDIS_URL, + }) + + beforeAll(async () => { + await redis.connect() + }) + + function createTestingLocker({ + useRedis = redis, + ...options + }: Partial[1]> & { useRedis?: typeof redis } = {}) { + const prefix = options.prefix ?? `orpc-redis-locker-${crypto.randomUUID()}:` + + return { prefix, - }), + locker: new RedisLocker(useRedis, { + ttl: 10_000, + retryInterval: 10, + ...options, + prefix, + }), + } } - } - /** - * Holds the lock until `release` is called, and resolves once the callback is running. - */ - async function hold(locker: Locker, key: string, options?: LockOptions) { - const started = promiseWithResolvers() - const finished = promiseWithResolvers() - const done = locker.lock(key, async ({ waited }) => { - started.resolve() - await finished.promise - return waited - }, options) - - await Promise.race([started.promise, done]) - - return { - release: () => { - finished.resolve() - return done - }, + /** + * Holds the lock until `release` is called, and resolves once the callback is running. + */ + async function hold(locker: Locker, key: string, options?: LockOptions) { + const started = promiseWithResolvers() + const finished = promiseWithResolvers() + const done = locker.lock( + key, + async ({ waited }) => { + started.resolve() + await finished.promise + return waited + }, + options, + ) + + await Promise.race([started.promise, done]) + + return { + release: () => { + finished.resolve() + return done + }, + } } - } - it('runs the callback right away when the lock is free and releases it afterwards', async () => { - const { locker } = createTestingLocker() - const fn = vi.fn(() => 'ok') + it('runs the callback right away when the lock is free and releases it afterwards', async () => { + const { locker } = createTestingLocker() + const fn = vi.fn(() => 'ok') + + await expect(locker.lock('key', fn)).resolves.toBe('ok') + expect(fn).toHaveBeenCalledExactlyOnceWith({ waited: false }) + + await expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again') + }) + + it('makes callers wait until the holder releases, across instances', async () => { + const key = `orpc-redis-locker-${crypto.randomUUID()}` + const locker = new RedisLocker(redis, { ttl: 10_000 }) + const other = new RedisLocker(redis, { ttl: 10_000 }) + const holder = await hold(locker, key) + const fn = vi.fn(() => 'ok') + const waiter = other.lock(key, fn) + + // Commands on one client run in order, so the waiter's first attempt was rejected as well + await expect(other.lock(key, () => 'never', { timeout: 0 })).rejects.toBeInstanceOf( + LockTimeoutError, + ) + + await holder.release() + await expect(waiter).resolves.toBe('ok') + expect(fn).toHaveBeenCalledExactlyOnceWith({ waited: true }) + }) - await expect(locker.lock('key', fn)).resolves.toBe('ok') - expect(fn).toHaveBeenCalledExactlyOnceWith({ waited: false }) + it('tracks locks independently per key', async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'alice') + const fn = vi.fn(() => 'bob') - await expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again') - }) + await expect(locker.lock('bob', fn, { timeout: 0 })).resolves.toBe('bob') + expect(fn).toHaveBeenCalledWith({ waited: false }) - it('makes callers wait until the holder releases, across instances', async () => { - const key = `orpc-redis-locker-${crypto.randomUUID()}` - const locker = new RedisLocker(redis, { ttl: 10_000 }) - const other = new RedisLocker(redis, { ttl: 10_000 }) - const holder = await hold(locker, key) - const fn = vi.fn(() => 'ok') - const waiter = other.lock(key, fn) + await holder.release() + }) - // Commands on one client run in order, so the waiter's first attempt was rejected as well - await expect(other.lock(key, () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) + it('shares locks with lockers created by prefix', async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'tenant:key') - await holder.release() - await expect(waiter).resolves.toBe('ok') - expect(fn).toHaveBeenCalledExactlyOnceWith({ waited: true }) - }) + await expect( + locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 }), + ).rejects.toBeInstanceOf(LockTimeoutError) + await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe( + 'ok', + ) - it('tracks locks independently per key', async () => { - const { locker } = createTestingLocker() - const holder = await hold(locker, 'alice') - const fn = vi.fn(() => 'bob') + await holder.release() + }) - await expect(locker.lock('bob', fn, { timeout: 0 })).resolves.toBe('bob') - expect(fn).toHaveBeenCalledWith({ waited: false }) + it('releases the lock when the callback throws', async () => { + const { locker } = createTestingLocker() - await holder.release() - }) + await expect( + locker.lock('key', () => { + throw new Error('boom') + }), + ).rejects.toThrow('boom') - it('shares locks with lockers created by prefix', async () => { - const { locker } = createTestingLocker() - const holder = await hold(locker, 'tenant:key') + await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + }) - await expect(locker.prefix('tenant:').lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) - await expect(locker.prefix('other:').lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + it('rejects with LockTimeoutError when the lock is not released in time', async () => { + const { locker } = createTestingLocker({ timeout: 200 }) + const holder = await hold(locker, 'key') + const fn = vi.fn() + const start = Date.now() + + await expect(locker.lock('key', fn)).rejects.toMatchObject({ + name: 'LockTimeoutError', + key: 'key', + message: 'Timed out waiting for the lock of key "key"', + }) + expect(Date.now() - start).toBeGreaterThanOrEqual(200) + expect(fn).not.toHaveBeenCalled() + + await holder.release() + }) + + it('hands the lock over when the ttl expires, and the expired holder cannot release it', async () => { + const { locker } = createTestingLocker({ ttl: 200 }) + const expired = await hold(locker, 'key') + const next = await hold(locker, 'key', { ttl: 10_000 }) - await holder.release() - }) + await expired.release() + await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf( + LockTimeoutError, + ) - it('releases the lock when the callback throws', async () => { - const { locker } = createTestingLocker() + await next.release() + await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + }) - await expect(locker.lock('key', () => { - throw new Error('boom') - })).rejects.toThrow('boom') + it('stops waiting when the signal is aborted', async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'key') + const controller = new AbortController() + const fn = vi.fn() + const waiter = locker.lock('key', fn, { signal: controller.signal }) - await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') - }) + controller.abort(new Error('aborted')) - it('rejects with LockTimeoutError when the lock is not released in time', async () => { - const { locker } = createTestingLocker({ timeout: 200 }) - const holder = await hold(locker, 'key') - const fn = vi.fn() - const start = Date.now() + await expect(waiter).rejects.toThrow('aborted') + await expect(locker.lock('key', fn, { signal: controller.signal })).rejects.toThrow('aborted') + expect(fn).not.toHaveBeenCalled() - await expect(locker.lock('key', fn)).rejects.toMatchObject({ - name: 'LockTimeoutError', - key: 'key', - message: 'Timed out waiting for the lock of key "key"', + await holder.release() }) - expect(Date.now() - start).toBeGreaterThanOrEqual(200) - expect(fn).not.toHaveBeenCalled() - - await holder.release() - }) - - it('hands the lock over when the ttl expires, and the expired holder cannot release it', async () => { - const { locker } = createTestingLocker({ ttl: 200 }) - const expired = await hold(locker, 'key') - const next = await hold(locker, 'key', { ttl: 10_000 }) - - await expired.release() - await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) - - await next.release() - await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') - }) - - it('stops waiting when the signal is aborted', async () => { - const { locker } = createTestingLocker() - const holder = await hold(locker, 'key') - const controller = new AbortController() - const fn = vi.fn() - const waiter = locker.lock('key', fn, { signal: controller.signal }) - - controller.abort(new Error('aborted')) - - await expect(waiter).rejects.toThrow('aborted') - await expect(locker.lock('key', fn, { signal: controller.signal })).rejects.toThrow('aborted') - expect(fn).not.toHaveBeenCalled() - - await holder.release() - }) - - it('never runs callbacks for the same key concurrently', async () => { - const { locker } = createTestingLocker() - let active = 0 - let maxActive = 0 - let count = 0 - - await Promise.all(Array.from({ length: 5 }, () => locker.lock('key', async () => { - active++ - maxActive = Math.max(maxActive, active) - const current = count - await sleep(5) - count = current + 1 - active-- - }))) - - expect(maxActive).toBe(1) - expect(count).toBe(5) - }) - - it('connects lazily and only once under concurrent lock calls', async () => { - const redis = createClient({ - url: REDIS_URL, + + it('never runs callbacks for the same key concurrently', async () => { + const { locker } = createTestingLocker() + let active = 0 + let maxActive = 0 + let count = 0 + + await Promise.all( + Array.from({ length: 5 }, () => + locker.lock('key', async () => { + active++ + maxActive = Math.max(maxActive, active) + const current = count + await sleep(5) + count = current + 1 + active-- + }), + ), + ) + + expect(maxActive).toBe(1) + expect(count).toBe(5) }) - const { locker } = createTestingLocker({ useRedis: redis }) - expect(redis.isOpen).toBe(false) + it('connects lazily and only once under concurrent lock calls', async () => { + const redis = createClient({ + url: REDIS_URL, + }) + const { locker } = createTestingLocker({ useRedis: redis }) - await expect(Promise.all([ - locker.lock('a', () => 'a'), - locker.lock('b', () => 'b'), - locker.lock('c', () => 'c'), - ])).resolves.toEqual(['a', 'b', 'c']) + expect(redis.isOpen).toBe(false) - expect(redis.isOpen).toBe(true) - }) -}) + await expect( + Promise.all([ + locker.lock('a', () => 'a'), + locker.lock('b', () => 'b'), + locker.lock('c', () => 'c'), + ]), + ).resolves.toEqual(['a', 'b', 'c']) + + expect(redis.isOpen).toBe(true) + }) + }, +) it('preserves the callback outcome when the release fails', async () => { - const redis = { isOpen: true, set: vi.fn(async () => 'OK'), eval: vi.fn().mockRejectedValue(new Error('connection lost')) } + const redis = { + isOpen: true, + set: vi.fn(async () => 'OK'), + eval: vi.fn().mockRejectedValue(new Error('connection lost')), + } const locker = new RedisLocker(redis as any, { ttl: 1000 }) const error = new Error('fn failed') diff --git a/packages/lock/src/adapters/redis.ts b/packages/lock/src/adapters/redis.ts index 888ca53c91..9f4d97ca1b 100644 --- a/packages/lock/src/adapters/redis.ts +++ b/packages/lock/src/adapters/redis.ts @@ -1,6 +1,7 @@ import type { RedisClientType } from 'redis' -import type { BaseRedisLockerOptions } from './base-redis' -import { BaseRedisLocker } from './base-redis' + +import type { BaseRedisLockerOptions } from './base-redis.ts' +import { BaseRedisLocker } from './base-redis.ts' /** * Locker adapter for Redis. Acquires locks with `SET NX PX` and releases them diff --git a/packages/lock/src/adapters/upstash.test.ts b/packages/lock/src/adapters/upstash.test.ts index efd3e69e33..7a224e7d09 100644 --- a/packages/lock/src/adapters/upstash.test.ts +++ b/packages/lock/src/adapters/upstash.test.ts @@ -1,8 +1,9 @@ -import type { Locker, LockOptions } from '../types' import { promiseWithResolvers, sleep } from '@orpc/shared' import { Redis } from '@upstash/redis' -import { LockTimeoutError } from '../error' -import { UpstashLocker } from './upstash' + +import { LockTimeoutError } from '../error.ts' +import type { Locker, LockOptions } from '../types.ts' +import { UpstashLocker } from './upstash.ts' const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN @@ -13,175 +14,199 @@ const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN * When writing new tests, always use unique keys to avoid conflicts with other * test cases. */ -describe.concurrent('upstash locker integration', { - skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, - timeout: 20_000, -}, () => { - const redis = new Redis({ - url: UPSTASH_REDIS_REST_URL, - token: UPSTASH_REDIS_REST_TOKEN, - }) +describe.concurrent( + 'upstash locker integration', + { + skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, + timeout: 20_000, + }, + () => { + const redis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + }) + + function createTestingLocker( + options: Partial[1]> = {}, + ) { + const prefix = options.prefix ?? `orpc-upstash-locker-${crypto.randomUUID()}:` - function createTestingLocker( - options: Partial[1]> = {}, - ) { - const prefix = options.prefix ?? `orpc-upstash-locker-${crypto.randomUUID()}:` - - return { - prefix, - locker: new UpstashLocker(redis, { - ttl: 10_000, - retryInterval: 50, - ...options, + return { prefix, - }), + locker: new UpstashLocker(redis, { + ttl: 10_000, + retryInterval: 50, + ...options, + prefix, + }), + } } - } - /** - * Holds the lock until `release` is called, and resolves once the callback is running. - */ - async function hold(locker: Locker, key: string, options?: LockOptions) { - const started = promiseWithResolvers() - const finished = promiseWithResolvers() - const done = locker.lock(key, async ({ waited }) => { - started.resolve() - await finished.promise - return waited - }, options) - - await Promise.race([started.promise, done]) - - return { - release: () => { - finished.resolve() - return done - }, + /** + * Holds the lock until `release` is called, and resolves once the callback is running. + */ + async function hold(locker: Locker, key: string, options?: LockOptions) { + const started = promiseWithResolvers() + const finished = promiseWithResolvers() + const done = locker.lock( + key, + async ({ waited }) => { + started.resolve() + await finished.promise + return waited + }, + options, + ) + + await Promise.race([started.promise, done]) + + return { + release: () => { + finished.resolve() + return done + }, + } } - } - - it('runs the callback right away when the lock is free and releases it afterwards', async () => { - const { locker } = createTestingLocker() - const fn = vi.fn(() => 'ok') - await expect(locker.lock('key', fn)).resolves.toBe('ok') - expect(fn).toHaveBeenCalledExactlyOnceWith({ waited: false }) + it('runs the callback right away when the lock is free and releases it afterwards', async () => { + const { locker } = createTestingLocker() + const fn = vi.fn(() => 'ok') - await expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again') - }) - - it('makes callers wait until the holder releases, across instances', async () => { - const key = `orpc-upstash-locker-${crypto.randomUUID()}` - const locker = new UpstashLocker(redis, { ttl: 10_000 }) - const other = new UpstashLocker(redis, { ttl: 10_000 }) - const holder = await hold(locker, key) - const fn = vi.fn(() => 'ok') - const waiter = other.lock(key, fn) + await expect(locker.lock('key', fn)).resolves.toBe('ok') + expect(fn).toHaveBeenCalledExactlyOnceWith({ waited: false }) - // Commands on one client run in order, so the waiter's first attempt was rejected as well - await expect(other.lock(key, () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) - - await holder.release() - await expect(waiter).resolves.toBe('ok') - expect(fn).toHaveBeenCalledExactlyOnceWith({ waited: true }) - }) - - it('tracks locks independently per key', async () => { - const { locker } = createTestingLocker() - const holder = await hold(locker, 'alice') - const fn = vi.fn(() => 'bob') + await expect(locker.lock('key', () => 'again', { timeout: 0 })).resolves.toBe('again') + }) - await expect(locker.lock('bob', fn, { timeout: 0 })).resolves.toBe('bob') - expect(fn).toHaveBeenCalledWith({ waited: false }) + it('makes callers wait until the holder releases, across instances', async () => { + const key = `orpc-upstash-locker-${crypto.randomUUID()}` + const locker = new UpstashLocker(redis, { ttl: 10_000 }) + const other = new UpstashLocker(redis, { ttl: 10_000 }) + const holder = await hold(locker, key) + const fn = vi.fn(() => 'ok') + const waiter = other.lock(key, fn) + + // Commands on one client run in order, so the waiter's first attempt was rejected as well + await expect(other.lock(key, () => 'never', { timeout: 0 })).rejects.toBeInstanceOf( + LockTimeoutError, + ) + + await holder.release() + await expect(waiter).resolves.toBe('ok') + expect(fn).toHaveBeenCalledExactlyOnceWith({ waited: true }) + }) - await holder.release() - }) + it('tracks locks independently per key', async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'alice') + const fn = vi.fn(() => 'bob') - it('releases the lock when the callback throws', async () => { - const { locker } = createTestingLocker() + await expect(locker.lock('bob', fn, { timeout: 0 })).resolves.toBe('bob') + expect(fn).toHaveBeenCalledWith({ waited: false }) - await expect(locker.lock('key', () => { - throw new Error('boom') - })).rejects.toThrow('boom') + await holder.release() + }) - await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') - }) + it('releases the lock when the callback throws', async () => { + const { locker } = createTestingLocker() - it('rejects with LockTimeoutError when the lock is not released in time', async () => { - const { locker } = createTestingLocker({ timeout: 200 }) - const holder = await hold(locker, 'key') - const fn = vi.fn() - const start = Date.now() + await expect( + locker.lock('key', () => { + throw new Error('boom') + }), + ).rejects.toThrow('boom') - await expect(locker.lock('key', fn)).rejects.toMatchObject({ - name: 'LockTimeoutError', - key: 'key', - message: 'Timed out waiting for the lock of key "key"', + await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') }) - expect(Date.now() - start).toBeGreaterThanOrEqual(200) - expect(fn).not.toHaveBeenCalled() - await holder.release() - }) + it('rejects with LockTimeoutError when the lock is not released in time', async () => { + const { locker } = createTestingLocker({ timeout: 200 }) + const holder = await hold(locker, 'key') + const fn = vi.fn() + const start = Date.now() + + await expect(locker.lock('key', fn)).rejects.toMatchObject({ + name: 'LockTimeoutError', + key: 'key', + message: 'Timed out waiting for the lock of key "key"', + }) + expect(Date.now() - start).toBeGreaterThanOrEqual(200) + expect(fn).not.toHaveBeenCalled() + + await holder.release() + }) - it('hands the lock over when the ttl expires, and the expired holder cannot release it', async () => { - const { locker } = createTestingLocker({ ttl: 200 }) - const expired = await hold(locker, 'key') - const next = await hold(locker, 'key', { ttl: 10_000 }) + it('hands the lock over when the ttl expires, and the expired holder cannot release it', async () => { + const { locker } = createTestingLocker({ ttl: 200 }) + const expired = await hold(locker, 'key') + const next = await hold(locker, 'key', { ttl: 10_000 }) - await expired.release() - await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf(LockTimeoutError) + await expired.release() + await expect(locker.lock('key', () => 'never', { timeout: 0 })).rejects.toBeInstanceOf( + LockTimeoutError, + ) - await next.release() - await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') - }) + await next.release() + await expect(locker.lock('key', () => 'ok', { timeout: 0 })).resolves.toBe('ok') + }) - it('stops waiting when the signal is aborted', async () => { - const { locker } = createTestingLocker() - const holder = await hold(locker, 'key') - const controller = new AbortController() - const fn = vi.fn() - const waiter = locker.lock('key', fn, { signal: controller.signal }) + it('stops waiting when the signal is aborted', async () => { + const { locker } = createTestingLocker() + const holder = await hold(locker, 'key') + const controller = new AbortController() + const fn = vi.fn() + const waiter = locker.lock('key', fn, { signal: controller.signal }) - controller.abort(new Error('aborted')) + controller.abort(new Error('aborted')) - await expect(waiter).rejects.toThrow('aborted') - await expect(locker.lock('key', fn, { signal: controller.signal })).rejects.toThrow('aborted') - expect(fn).not.toHaveBeenCalled() + await expect(waiter).rejects.toThrow('aborted') + await expect(locker.lock('key', fn, { signal: controller.signal })).rejects.toThrow('aborted') + expect(fn).not.toHaveBeenCalled() - await holder.release() - }) + await holder.release() + }) - it('never runs callbacks for the same key concurrently', async () => { - const { locker } = createTestingLocker() - let active = 0 - let maxActive = 0 - let count = 0 - - await Promise.all(Array.from({ length: 5 }, () => locker.lock('key', async () => { - active++ - maxActive = Math.max(maxActive, active) - const current = count - await sleep(5) - count = current + 1 - active-- - }))) - - expect(maxActive).toBe(1) - expect(count).toBe(5) - }) -}) + it('never runs callbacks for the same key concurrently', async () => { + const { locker } = createTestingLocker() + let active = 0 + let maxActive = 0 + let count = 0 + + await Promise.all( + Array.from({ length: 5 }, () => + locker.lock('key', async () => { + active++ + maxActive = Math.max(maxActive, active) + const current = count + await sleep(5) + count = current + 1 + active-- + }), + ), + ) + + expect(maxActive).toBe(1) + expect(count).toBe(5) + }) + }, +) it('prefix creates a locker that appends the prefix and shares the client', async () => { const redis = { set: vi.fn(async () => 'OK'), eval: vi.fn(async () => 1) } const locker = new UpstashLocker(redis as any, { ttl: 1000, prefix: 'app:' }).prefix('tenant:') await expect(locker.lock('key', () => 'ok')).resolves.toBe('ok') - expect(redis.set).toHaveBeenCalledWith('app:tenant:key', expect.any(String), { nx: true, px: 1000 }) + expect(redis.set).toHaveBeenCalledWith('app:tenant:key', expect.any(String), { + nx: true, + px: 1000, + }) }) it('preserves the callback outcome when the release fails', async () => { - const redis = { set: vi.fn(async () => 'OK'), eval: vi.fn().mockRejectedValue(new Error('connection lost')) } + const redis = { + set: vi.fn(async () => 'OK'), + eval: vi.fn().mockRejectedValue(new Error('connection lost')), + } const locker = new UpstashLocker(redis as any, { ttl: 1000 }) const error = new Error('fn failed') diff --git a/packages/lock/src/adapters/upstash.ts b/packages/lock/src/adapters/upstash.ts index 220366978a..3a2def8745 100644 --- a/packages/lock/src/adapters/upstash.ts +++ b/packages/lock/src/adapters/upstash.ts @@ -1,6 +1,7 @@ import type { Redis } from '@upstash/redis' -import type { BaseRedisLockerOptions } from './base-redis' -import { BaseRedisLocker } from './base-redis' + +import type { BaseRedisLockerOptions } from './base-redis.ts' +import { BaseRedisLocker } from './base-redis.ts' /** * Locker adapter for Upstash Redis. Acquires locks with `SET NX PX` and releases diff --git a/packages/lock/src/index.test.ts b/packages/lock/src/index.test.ts index f5948f4a6f..bb4ee6a55b 100644 --- a/packages/lock/src/index.test.ts +++ b/packages/lock/src/index.test.ts @@ -1,5 +1,5 @@ it('exports middleware factory and error', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ lock: expect.any(Function), LockTimeoutError: expect.any(Function), }) diff --git a/packages/lock/src/index.ts b/packages/lock/src/index.ts index 863938a3f4..cc708514ae 100644 --- a/packages/lock/src/index.ts +++ b/packages/lock/src/index.ts @@ -1,3 +1,3 @@ -export * from './error' -export * from './middleware' -export * from './types' +export * from './error.ts' +export * from './middleware.ts' +export * from './types.ts' diff --git a/packages/lock/src/middleware.test-d.ts b/packages/lock/src/middleware.test-d.ts index c0f1586db9..2b99bf49a3 100644 --- a/packages/lock/src/middleware.test-d.ts +++ b/packages/lock/src/middleware.test-d.ts @@ -1,11 +1,12 @@ -import type { Locker } from './types' import { os, type } from '@orpc/server' -import { lock } from './middleware' + +import { lock } from './middleware.ts' +import type { Locker } from './types.ts' describe('lock', () => { it('can infer context & input & meta types', () => { const procedure = os - .$context<{ userId: string, locker: Locker }>() + .$context<{ userId: string; locker: Locker }>() .input(type<{ id: string }>()) .use(({ next }) => { return next({ diff --git a/packages/lock/src/middleware.test.ts b/packages/lock/src/middleware.test.ts index af7921a2e7..36173f3c06 100644 --- a/packages/lock/src/middleware.test.ts +++ b/packages/lock/src/middleware.test.ts @@ -1,7 +1,8 @@ -import type { Locker } from './types' import { call, ORPCError, os, type } from '@orpc/server' -import { LockTimeoutError } from './error' -import { lock, LOCK_MIDDLEWARE_CONTEXT_SYMBOL } from './middleware' + +import { LockTimeoutError } from './error.ts' +import { lock, LOCK_MIDDLEWARE_CONTEXT_SYMBOL } from './middleware.ts' +import type { Locker } from './types.ts' describe('lock', () => { const createLocker = (waited = false): Locker => ({ @@ -13,33 +14,41 @@ describe('lock', () => { const mw = lock({ locker, key: 'key' }) const procedure = os.use(mw).handler(({ context }) => context['lock/waited']) - await expect( - call(procedure, undefined, { context: {} }), - ).resolves.toBe(false) + await expect(call(procedure, undefined, { context: {} })).resolves.toBe(false) expect(locker.lock).toHaveBeenCalledTimes(1) - expect(locker.lock).toHaveBeenCalledWith('key', expect.any(Function), { ttl: undefined, timeout: undefined, signal: undefined }) + expect(locker.lock).toHaveBeenCalledWith('key', expect.any(Function), { + ttl: undefined, + timeout: undefined, + signal: undefined, + }) }) it('exposes whether the lock was waited for', async () => { const locker = createLocker(true) - const procedure = os.use(lock({ locker, key: 'key' })).handler(({ context }) => context['lock/waited']) + const procedure = os + .use(lock({ locker, key: 'key' })) + .handler(({ context }) => context['lock/waited']) - await expect( - call(procedure, undefined, { context: {} }), - ).resolves.toBe(true) + await expect(call(procedure, undefined, { context: {} })).resolves.toBe(true) }) it('can config ttl, timeout and forwards the signal', async () => { const locker = createLocker() const controller = new AbortController() - const procedure = os.use(lock({ locker, key: 'key', ttl: 5000, timeout: 1000 })).handler(() => 'ok') + const procedure = os + .use(lock({ locker, key: 'key', ttl: 5000, timeout: 1000 })) + .handler(() => 'ok') await expect( call(procedure, undefined, { context: {}, signal: controller.signal }), ).resolves.toBe('ok') - expect(locker.lock).toHaveBeenCalledWith('key', expect.any(Function), { ttl: 5000, timeout: 1000, signal: controller.signal }) + expect(locker.lock).toHaveBeenCalledWith('key', expect.any(Function), { + ttl: 5000, + timeout: 1000, + signal: controller.signal, + }) }) it('locker, key, ttl, timeout can be async functions', async () => { @@ -49,13 +58,20 @@ describe('lock', () => { const ttlFn = vi.fn().mockResolvedValueOnce(5000) const timeoutFn = vi.fn().mockResolvedValueOnce(1000) const mw = lock({ locker: lockerFn, key: keyFn, ttl: ttlFn, timeout: timeoutFn }) - const procedure = os.input(type()).use(mw).handler(() => 'ok') + const procedure = os + .input(type()) + .use(mw) + .handler(() => 'ok') await expect( call(procedure, '__input__', { context: { __context__: true }, path: ['__path__'] }), ).resolves.toBe('ok') - expect(locker.lock).toHaveBeenCalledWith('key', expect.any(Function), { ttl: 5000, timeout: 1000, signal: undefined }) + expect(locker.lock).toHaveBeenCalledWith('key', expect.any(Function), { + ttl: 5000, + timeout: 1000, + signal: undefined, + }) for (const fn of [lockerFn, keyFn, ttlFn, timeoutFn]) { expect(fn).toHaveBeenCalledTimes(1) @@ -72,7 +88,10 @@ describe('lock', () => { const handler = vi.fn() const procedure = os.use(lock({ locker, key: 'key' })).handler(handler) - const thrown = await call(procedure, undefined, { context: {} }).then(() => undefined, e => e) + const thrown = await call(procedure, undefined, { context: {} }).then( + () => undefined, + (e) => e, + ) expect(thrown).toBeInstanceOf(ORPCError) expect(thrown.code).toBe('CONFLICT') @@ -85,9 +104,7 @@ describe('lock', () => { const locker: Locker = { lock: vi.fn().mockRejectedValue(error) } const procedure = os.use(lock({ locker, key: 'key' })).handler(() => 'ok') - await expect( - call(procedure, undefined, { context: {} }), - ).rejects.toBe(error) + await expect(call(procedure, undefined, { context: {} })).rejects.toBe(error) }) it('passes through errors thrown by the handler, even LockTimeoutError', async () => { @@ -97,23 +114,17 @@ describe('lock', () => { throw error }) - await expect( - call(procedure, undefined, { context: {} }), - ).rejects.toBe(error) + await expect(call(procedure, undefined, { context: {} })).rejects.toBe(error) }) it('communicate with middleware context, and isolated', async () => { const locker = createLocker() const mw = lock({ locker, key: 'k', dedupe: false }) const innerHandlerFn = vi.fn().mockReturnValue('in') - const inner = os - .use(mw) - .handler(innerHandlerFn) - const outer = os - .use(mw) - .handler(async ({ context }) => { - return `out:${await call(inner, undefined, { context })}:${await call(inner, undefined, { context })}` - }) + const inner = os.use(mw).handler(innerHandlerFn) + const outer = os.use(mw).handler(async ({ context }) => { + return `out:${await call(inner, undefined, { context })}:${await call(inner, undefined, { context })}` + }) await call(outer, undefined, { context: {} }) @@ -137,11 +148,12 @@ describe('lock', () => { it('deduplicates by default', async () => { const locker = createLocker(true) const mw = lock({ locker, key: 'k' }) - const procedure = os.use(mw).use(mw).handler(({ context }) => context['lock/waited']) + const procedure = os + .use(mw) + .use(mw) + .handler(({ context }) => context['lock/waited']) - await expect( - call(procedure, undefined, { context: {} }), - ).resolves.toBe(true) + await expect(call(procedure, undefined, { context: {} })).resolves.toBe(true) expect(locker.lock).toHaveBeenCalledTimes(1) }) @@ -150,7 +162,10 @@ describe('lock', () => { const locker = createLocker() const mw = lock({ locker, key: 'k', dedupe: false }) await call( - os.use(mw).use(mw).handler(() => 'ok'), + os + .use(mw) + .use(mw) + .handler(() => 'ok'), undefined, { context: {} }, ) @@ -174,16 +189,10 @@ describe('lock', () => { it('dedupes in nested calls', async () => { const locker = createLocker(true) const mw = lock({ locker, key: 'k' }) - const inner = os - .use(mw) - .handler(({ context }) => context['lock/waited']) - const outer = os - .use(mw) - .handler(async ({ context }) => call(inner, undefined, { context })) + const inner = os.use(mw).handler(({ context }) => context['lock/waited']) + const outer = os.use(mw).handler(async ({ context }) => call(inner, undefined, { context })) - await expect( - call(outer, undefined, { context: {} }), - ).resolves.toBe(true) + await expect(call(outer, undefined, { context: {} })).resolves.toBe(true) expect(locker.lock).toHaveBeenCalledTimes(1) }) @@ -191,8 +200,10 @@ describe('lock', () => { it('respects per-instance dedupe', async () => { const locker = createLocker() await call( - os.use(lock({ locker, key: 'k', dedupe: true })) - .use(lock({ locker, key: 'k', dedupe: false })).handler(() => 'ok'), + os + .use(lock({ locker, key: 'k', dedupe: true })) + .use(lock({ locker, key: 'k', dedupe: false })) + .handler(() => 'ok'), undefined, { context: {} }, ) diff --git a/packages/lock/src/middleware.ts b/packages/lock/src/middleware.ts index 49122f80ca..08a84c4048 100644 --- a/packages/lock/src/middleware.ts +++ b/packages/lock/src/middleware.ts @@ -1,18 +1,21 @@ import type { Context, Middleware, MiddlewareOptions } from '@orpc/server' -import type { Promisable, Value } from '@orpc/shared' -import type { Locker } from './types' import { ORPCError } from '@orpc/server' +import type { Promisable, Value } from '@orpc/shared' import { toArray, value } from '@orpc/shared' -import { LockTimeoutError } from './error' -export const LOCK_MIDDLEWARE_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for('ORPC_LOCK_MIDDLEWARE_CONTEXT') +import { LockTimeoutError } from './error.ts' +import type { Locker } from './types.ts' + +export const LOCK_MIDDLEWARE_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for( + 'ORPC_LOCK_MIDDLEWARE_CONTEXT', +) export interface LockMiddlewareContext { [LOCK_MIDDLEWARE_CONTEXT_SYMBOL]?: { /** * The locks held in this request, mainly for deduplication purposes */ - held: { locker: Locker, key: string, waited: boolean }[] + held: { locker: Locker; key: string; waited: boolean }[] } } @@ -24,33 +27,46 @@ export interface LockMiddlewareOutContext { ['lock/waited']: boolean } -export interface LockMiddlewareOptions< - TInContext extends Context, - TInput, -> { +export interface LockMiddlewareOptions { /** * The locker to acquire the lock from */ - locker: Value, [options: MiddlewareOptions>, input: TInput]> + locker: Value< + Promisable, + [options: MiddlewareOptions>, input: TInput] + > /** * The key to identify the work that must not run concurrently */ - key: Value, [options: MiddlewareOptions>, input: TInput]> + key: Value< + Promisable, + [options: MiddlewareOptions>, input: TInput] + > /** * How long the lock is held before it expires automatically, in milliseconds. * * @default the adapter default */ - ttl?: Value, [options: MiddlewareOptions>, input: TInput]> | undefined + ttl?: + | Value< + Promisable, + [options: MiddlewareOptions>, input: TInput] + > + | undefined /** * How long to wait for the lock to become available, in milliseconds. * * @default the adapter default */ - timeout?: Value, [options: MiddlewareOptions>, input: TInput]> | undefined + timeout?: + | Value< + Promisable, + [options: MiddlewareOptions>, input: TInput] + > + | undefined /** * If your lock middleware is used multiple times @@ -71,12 +87,16 @@ export interface LockMiddlewareOptions< * * @see {@link https://orpc.dev/docs/helpers/lock#lock-middleware | Lock Helpers - Lock Middleware} */ -export function lock< - TInContext extends Context, +export function lock({ + dedupe = true, + ...options +}: LockMiddlewareOptions): Middleware< + TInContext, + LockMiddlewareOutContext, TInput, ->( - { dedupe = true, ...options }: LockMiddlewareOptions, -): Middleware { + any, + object +> { return async function lock(middlewareOptions, input) { const [locker, key, ttl, timeout] = await Promise.all([ value(options.locker, middlewareOptions, input), @@ -85,8 +105,10 @@ export function lock< value(options.timeout, middlewareOptions, input), ]) - const middlewareContext = (middlewareOptions.context as LockMiddlewareContext)[LOCK_MIDDLEWARE_CONTEXT_SYMBOL] - const held = middlewareContext?.held.find(l => l.key === key && l.locker === locker) + const middlewareContext = (middlewareOptions.context as LockMiddlewareContext)[ + LOCK_MIDDLEWARE_CONTEXT_SYMBOL + ] + const held = middlewareContext?.held.find((l) => l.key === key && l.locker === locker) if (dedupe && held) { return middlewareOptions.next({ @@ -99,24 +121,24 @@ export function lock< let acquired = false try { - return await locker.lock(key, ({ waited }) => { - acquired = true - - return middlewareOptions.next({ - context: { - 'lock/waited': waited, - [LOCK_MIDDLEWARE_CONTEXT_SYMBOL]: { - ...middlewareContext, - held: [ - ...toArray(middlewareContext?.held), - { locker, key, waited }, - ], - }, - } satisfies LockMiddlewareOutContext & LockMiddlewareContext, - }) - }, { ttl, timeout, signal: middlewareOptions.signal }) - } - catch (error) { + return await locker.lock( + key, + ({ waited }) => { + acquired = true + + return middlewareOptions.next({ + context: { + 'lock/waited': waited, + [LOCK_MIDDLEWARE_CONTEXT_SYMBOL]: { + ...middlewareContext, + held: [...toArray(middlewareContext?.held), { locker, key, waited }], + }, + } satisfies LockMiddlewareOutContext & LockMiddlewareContext, + }) + }, + { ttl, timeout, signal: middlewareOptions.signal }, + ) + } catch (error) { if (!acquired && error instanceof LockTimeoutError) { throw new ORPCError('CONFLICT', { cause: error }) } diff --git a/packages/lock/src/types.ts b/packages/lock/src/types.ts index 977f2a5b85..c77db101db 100644 --- a/packages/lock/src/types.ts +++ b/packages/lock/src/types.ts @@ -44,5 +44,9 @@ export interface Locker { * * @throws {LockTimeoutError} when the lock cannot be acquired before the timeout elapses */ - lock(key: string, fn: (options: LockCallbackOptions) => Promisable, options?: LockOptions): Promise + lock( + key: string, + fn: (options: LockCallbackOptions) => Promisable, + options?: LockOptions, + ): Promise } diff --git a/packages/lock/tests/e2e.test.ts b/packages/lock/tests/e2e.test.ts index d199860a65..7e8843f7aa 100644 --- a/packages/lock/tests/e2e.test.ts +++ b/packages/lock/tests/e2e.test.ts @@ -1,10 +1,11 @@ -import type { Locker } from '../src' import { os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { promiseWithResolvers, sleep } from '@orpc/shared' import { z } from 'zod' -import { lock } from '../src' -import { MemoryLocker } from '../src/adapters/memory' + +import { MemoryLocker } from '../src/adapters/memory.ts' +import type { Locker } from '../src/index.ts' +import { lock } from '../src/index.ts' it('works', async () => { let active = 0 @@ -34,13 +35,14 @@ it('works', async () => { const handler = new RPCHandler(router) const locker = new MemoryLocker() - const request = (id: string) => new Request('https://example.com/generate', { - method: 'POST', - body: JSON.stringify({ json: { id } }), - headers: { - 'Content-Type': 'application/json', - }, - }) + const request = (id: string) => + new Request('https://example.com/generate', { + method: 'POST', + body: JSON.stringify({ json: { id } }), + headers: { + 'Content-Type': 'application/json', + }, + }) const [first, second] = await Promise.all([ handler.handle(request('1'), { context: { locker } }), diff --git a/packages/lock/tsconfig.json b/packages/lock/tsconfig.json index 7794b46fc2..9979a0715c 100644 --- a/packages/lock/tsconfig.json +++ b/packages/lock/tsconfig.json @@ -1,16 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../server" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.bench.*", - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 3ea9f276be..ee526c8e02 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/mcp/build.config.ts b/packages/mcp/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/mcp/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 4d7c3aa7df..2655be6077 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,54 +1,50 @@ { "name": "@orpc/mcp", - "type": "module", "version": "2.0.0-beta.48", "description": "MCP integration for oRPC: expose procedures as typesafe Model Context Protocol tools", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "agents", + "ai", + "llm", + "mcp", + "model-context-protocol", + "orpc", + "tools", + "typesafe", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/mcp" }, - "keywords": [ - "orpc", - "mcp", - "model-context-protocol", - "ai", - "tools", - "llm", - "agents", - "typescript", - "typesafe" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "@modelcontextprotocol/server": ">=2.0.0" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", @@ -64,5 +60,8 @@ "@modelcontextprotocol/server": "^2.4.0", "@orpc/zod": "workspace:*", "zod": "^4.6.7" + }, + "peerDependencies": { + "@modelcontextprotocol/server": ">=2.0.0" } } diff --git a/packages/mcp/src/__tests__/utils.ts b/packages/mcp/src/__tests__/utils.ts index d3a96df686..b385a3774e 100644 --- a/packages/mcp/src/__tests__/utils.ts +++ b/packages/mcp/src/__tests__/utils.ts @@ -1,7 +1,8 @@ -import type { McpRegistrar, McpRegistrarRegisterOptions } from '../registrar' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' import { createMcpHandler, InMemoryTransport, McpServer } from '@modelcontextprotocol/server' +import type { McpRegistrar, McpRegistrarRegisterOptions } from '../registrar.ts' + export type ProtocolEra = 'legacy' | 'modern' export const PROTOCOL_ERAS: ProtocolEra[] = ['legacy', 'modern'] @@ -18,7 +19,10 @@ export function createTestServer(): McpServer { export async function createTestClient( era: ProtocolEra, registrar: McpRegistrar, - { context, requestInit }: { context?: McpRegistrarRegisterOptions['context'], requestInit?: RequestInit } = {}, + { + context, + requestInit, + }: { context?: McpRegistrarRegisterOptions['context']; requestInit?: RequestInit } = {}, ): Promise { const createServer = async () => { const server = createTestServer() @@ -37,15 +41,16 @@ export async function createTestClient( const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await (await createServer()).connect(serverTransport) await client.connect(clientTransport) - } - else { + } else { const handler = createMcpHandler(createServer, { legacy: 'reject' }) onTestFinished(() => handler.close()) - await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { - fetch: (url, init) => handler.fetch(new Request(url, init)), - requestInit, - })) + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)), + requestInit, + }), + ) } return client diff --git a/packages/mcp/src/index.test.ts b/packages/mcp/src/index.test.ts index 8f31ec6736..aa22b905d6 100644 --- a/packages/mcp/src/index.test.ts +++ b/packages/mcp/src/index.test.ts @@ -1,3 +1,5 @@ it('exports McpRegistrar and mcp', async () => { - expect(Object.keys(await import('./index'))).toEqual(expect.arrayContaining(['McpRegistrar', 'mcp', 'getMcpToolMeta'])) + expect(Object.keys(await import('./index.ts'))).toEqual( + expect.arrayContaining(['McpRegistrar', 'mcp', 'getMcpToolMeta']), + ) }) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index cfaa0b04c4..e1f3841b1a 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,2 +1,2 @@ -export * from './meta' -export * from './registrar' +export * from './meta.ts' +export * from './registrar.ts' diff --git a/packages/mcp/src/meta.test-d.ts b/packages/mcp/src/meta.test-d.ts index 896cea4779..96d2054ed6 100644 --- a/packages/mcp/src/meta.test-d.ts +++ b/packages/mcp/src/meta.test-d.ts @@ -1,12 +1,15 @@ import { oc } from '@orpc/contract' import { os } from '@orpc/server' import { z } from 'zod' -import { mcp } from './meta' + +import { mcp } from './meta.ts' describe('mcp.tool meta', () => { it('can be used with contracts and procedures', () => { oc.meta(mcp.tool({ name: 'get_planet' })).input(z.object({ id: z.number() })) - os.meta(mcp.tool({ title: 'Get a planet' })).input(z.object({ id: z.number() })).handler(() => {}) + os.meta(mcp.tool({ title: 'Get a planet' })) + .input(z.object({ id: z.number() })) + .handler(() => {}) }) it('types the tool options', () => { @@ -14,7 +17,12 @@ describe('mcp.tool meta', () => { name: 'get_planet', title: 'Get a planet', description: 'Get a planet by id', - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, icons: [{ src: 'https://example.com/icon.png', mimeType: 'image/png', sizes: ['48x48'] }], _meta: { source: 'planets' }, }) diff --git a/packages/mcp/src/meta.test.ts b/packages/mcp/src/meta.test.ts index 5b179ac9c9..d5947c0a5f 100644 --- a/packages/mcp/src/meta.test.ts +++ b/packages/mcp/src/meta.test.ts @@ -1,6 +1,7 @@ import { oc } from '@orpc/contract' import { os } from '@orpc/server' -import { getMcpToolMeta, mcp } from './meta' + +import { getMcpToolMeta, mcp } from './meta.ts' describe('mcp.tool meta', () => { it('returns a plugin with name ~mcp/tool', () => { @@ -8,7 +9,11 @@ describe('mcp.tool meta', () => { }) it('init meta on first time use', () => { - const meta = { name: 'get_planet', description: 'Get a planet', annotations: { readOnlyHint: true } } + const meta = { + name: 'get_planet', + description: 'Get a planet', + annotations: { readOnlyHint: true }, + } expect(getMcpToolMeta(oc.meta(mcp.tool(meta)))).toEqual(meta) expect(getMcpToolMeta(os.meta(mcp.tool(meta)))).toEqual(meta) }) @@ -18,13 +23,22 @@ describe('mcp.tool meta', () => { }) it('reads meta from lazy routers', () => { - const lazy = os.meta(mcp.tool({ title: 'Planets' })).lazy(() => Promise.resolve({ default: {} })) + const lazy = os + .meta(mcp.tool({ title: 'Planets' })) + .lazy(() => Promise.resolve({ default: {} })) expect(getMcpToolMeta(lazy)).toEqual({ title: 'Planets' }) }) it('overrides fields with the most recent call, but merges annotations', () => { const contract = oc - .meta(mcp.tool({ name: 'first', title: 'First', _meta: { a: 1 }, annotations: { readOnlyHint: true, openWorldHint: true } })) + .meta( + mcp.tool({ + name: 'first', + title: 'First', + _meta: { a: 1 }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }), + ) .meta(mcp.tool({ name: 'second', _meta: { b: 2 }, annotations: { openWorldHint: false } })) expect(getMcpToolMeta(contract)).toEqual({ @@ -36,16 +50,24 @@ describe('mcp.tool meta', () => { }) it('keeps or resets annotations when only one call defines them', () => { - expect(getMcpToolMeta( - oc.meta(mcp.tool({ annotations: { readOnlyHint: true } })).meta(mcp.tool({ name: 'name' })), - )?.annotations).toEqual({ readOnlyHint: true }) + expect( + getMcpToolMeta( + oc.meta(mcp.tool({ annotations: { readOnlyHint: true } })).meta(mcp.tool({ name: 'name' })), + )?.annotations, + ).toEqual({ readOnlyHint: true }) - expect(getMcpToolMeta( - oc.meta(mcp.tool({ name: 'name' })).meta(mcp.tool({ annotations: { readOnlyHint: true } })), - )?.annotations).toEqual({ readOnlyHint: true }) + expect( + getMcpToolMeta( + oc.meta(mcp.tool({ name: 'name' })).meta(mcp.tool({ annotations: { readOnlyHint: true } })), + )?.annotations, + ).toEqual({ readOnlyHint: true }) - expect(getMcpToolMeta( - oc.meta(mcp.tool({ annotations: { readOnlyHint: true } })).meta(mcp.tool({ annotations: undefined })), - )?.annotations).toBeUndefined() + expect( + getMcpToolMeta( + oc + .meta(mcp.tool({ annotations: { readOnlyHint: true } })) + .meta(mcp.tool({ annotations: undefined })), + )?.annotations, + ).toBeUndefined() }) }) diff --git a/packages/mcp/src/meta.ts b/packages/mcp/src/meta.ts index 419959272e..0217cc32b2 100644 --- a/packages/mcp/src/meta.ts +++ b/packages/mcp/src/meta.ts @@ -103,7 +103,7 @@ export interface McpMetaFunctions { * @see {@link https://orpc.dev/docs/integrations/mcp#tool-meta | MCP Integration - Tool Meta} */ export const mcp: McpMetaFunctions = { - tool: incoming => ({ + tool: (incoming) => ({ name: '~mcp/tool', init(meta) { const existing = meta['~mcp/tool'] as undefined | McpToolMeta @@ -128,6 +128,8 @@ export const mcp: McpMetaFunctions = { * * @see {@link https://orpc.dev/docs/integrations/mcp#tool-meta | MCP Integration - Tool Meta} */ -export function getMcpToolMeta(procedureOrLazy: AnyProcedureContract | Lazy): McpToolMeta | undefined { +export function getMcpToolMeta( + procedureOrLazy: AnyProcedureContract | Lazy, +): McpToolMeta | undefined { return procedureOrLazy['~orpc'].meta['~mcp/tool'] as McpToolMeta | undefined } diff --git a/packages/mcp/src/registrar.test-d.ts b/packages/mcp/src/registrar.test-d.ts index f7b9973b05..59aeeffa75 100644 --- a/packages/mcp/src/registrar.test-d.ts +++ b/packages/mcp/src/registrar.test-d.ts @@ -1,20 +1,23 @@ import type { RegisteredTool, ServerContext } from '@modelcontextprotocol/server' -import type { AnyProcedureContract } from '@orpc/contract' -import type { AnyProcedure, StandardLazyRequest } from '@orpc/server' -import type { McpRegistered } from './registrar' import { McpServer } from '@modelcontextprotocol/server' +import type { AnyProcedureContract } from '@orpc/contract' import { oc } from '@orpc/contract' import { SmartCoercionHandlerPlugin } from '@orpc/json-schema' import { OpenAPIJsonSerializer, OpenAPISerializer } from '@orpc/openapi' +import type { AnyProcedure, StandardLazyRequest } from '@orpc/server' import { implement, os } from '@orpc/server' import { z } from 'zod' -import { McpRegistrar } from './registrar' + +import type { McpRegistered } from './registrar.ts' +import { McpRegistrar } from './registrar.ts' const server = new McpServer({ name: 'test-server', version: '1.0.0' }) describe('mcpRegistrar', () => { it('returns the registered tools by name', () => { - expectTypeOf(new McpRegistrar({ ping: os.handler(() => 'pong') }).register(server)).toEqualTypeOf>() + expectTypeOf( + new McpRegistrar({ ping: os.handler(() => 'pong') }).register(server), + ).toEqualTypeOf>() expectTypeOf().toEqualTypeOf<{ tools: Record @@ -24,13 +27,19 @@ describe('mcpRegistrar', () => { it('accepts procedures, routers, nested lazy routers, and implemented contracts', () => { void new McpRegistrar(os.handler(() => {})) void new McpRegistrar({ planet: { list: os.handler(() => []) } }) - void new McpRegistrar({ planet: os.lazy(() => Promise.resolve({ default: { list: os.handler(() => []) } })) }) + void new McpRegistrar({ + planet: os.lazy(() => Promise.resolve({ default: { list: os.handler(() => []) } })), + }) - // @ts-expect-error a lazy root router is not accepted, create the registrar lazily instead - void new McpRegistrar(os.lazy(() => Promise.resolve({ default: { list: os.handler(() => []) } }))) + void new McpRegistrar( + // @ts-expect-error a lazy root router is not accepted, create the registrar lazily instead + os.lazy(() => Promise.resolve({ default: { list: os.handler(() => []) } })), + ) const contract = { ping: oc.input(z.object({ id: z.number() })) } - void new McpRegistrar(implement(contract).router({ ping: implement(contract).ping.handler(() => {}) })) + void new McpRegistrar( + implement(contract).router({ ping: implement(contract).ping.handler(() => {}) }), + ) // @ts-expect-error contracts are not implemented void new McpRegistrar(contract) @@ -60,7 +69,9 @@ describe('mcpRegistrar', () => { }) it('makes context optional when the initial context can be empty', () => { - void new McpRegistrar({ ping: os.$context<{ auth?: string }>().handler(() => 'pong') }).register(server) + void new McpRegistrar({ + ping: os.$context<{ auth?: string }>().handler(() => 'pong'), + }).register(server) void new McpRegistrar({ ping: os.handler(() => 'pong') }).register(server, { context: {} }) }) @@ -70,50 +81,65 @@ describe('mcpRegistrar', () => { }) it('types interceptors with the initial context', () => { - void new McpRegistrar({ ping: os.$context<{ auth: string }>().handler(() => 'pong') }, { - interceptors: [ - ({ next, context }) => { - expectTypeOf(context.auth).toEqualTypeOf() - return next() - }, - ], - }) + void new McpRegistrar( + { ping: os.$context<{ auth: string }>().handler(() => 'pong') }, + { + interceptors: [ + ({ next, context }) => { + expectTypeOf(context.auth).toEqualTypeOf() + return next() + }, + ], + }, + ) }) it('types the filter and standard handler options', () => { - void new McpRegistrar({ ping: os.$context<{ auth: string }>().handler(() => 'pong') }, { - filter: (contract, path) => { - expectTypeOf(contract).toEqualTypeOf() - expectTypeOf(path).toEqualTypeOf() - return true - }, - clientInterceptors: [ - ({ next, path, context }) => { + void new McpRegistrar( + { ping: os.$context<{ auth: string }>().handler(() => 'pong') }, + { + filter: (contract, path) => { + expectTypeOf(contract).toEqualTypeOf() expectTypeOf(path).toEqualTypeOf() - expectTypeOf(context.auth).toEqualTypeOf() - return next() - }, - ], - interceptors: [ - ({ next, request, context }) => { - expectTypeOf(request).toEqualTypeOf() - expectTypeOf(context.auth).toEqualTypeOf() - return next() - }, - ], - routingInterceptors: [ - ({ next, request, context }) => { - expectTypeOf(request).toEqualTypeOf() - expectTypeOf(context.auth).toEqualTypeOf() - return next() + return true }, - ], - plugins: [new SmartCoercionHandlerPlugin()], - }) - - void new McpRegistrar({ ping: os.handler(() => 'pong') }, { serializer: new OpenAPIJsonSerializer() }) - void new McpRegistrar({ ping: os.handler(() => 'pong') }, { serializer: new OpenAPISerializer() }) - // @ts-expect-error not a JSON serializer - void new McpRegistrar({ ping: os.handler(() => 'pong') }, { serializer: { serialize: (data: unknown) => data } }) + clientInterceptors: [ + ({ next, path, context }) => { + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(context.auth).toEqualTypeOf() + return next() + }, + ], + interceptors: [ + ({ next, request, context }) => { + expectTypeOf(request).toEqualTypeOf() + expectTypeOf(context.auth).toEqualTypeOf() + return next() + }, + ], + routingInterceptors: [ + ({ next, request, context }) => { + expectTypeOf(request).toEqualTypeOf() + expectTypeOf(context.auth).toEqualTypeOf() + return next() + }, + ], + plugins: [new SmartCoercionHandlerPlugin()], + }, + ) + + void new McpRegistrar( + { ping: os.handler(() => 'pong') }, + { serializer: new OpenAPIJsonSerializer() }, + ) + void new McpRegistrar( + { ping: os.handler(() => 'pong') }, + { serializer: new OpenAPISerializer() }, + ) + void new McpRegistrar( + { ping: os.handler(() => 'pong') }, + // @ts-expect-error not a JSON serializer + { serializer: { serialize: (data: unknown) => data } }, + ) }) }) diff --git a/packages/mcp/src/registrar.test.ts b/packages/mcp/src/registrar.test.ts index e819c309a0..737dfb5876 100644 --- a/packages/mcp/src/registrar.test.ts +++ b/packages/mcp/src/registrar.test.ts @@ -1,26 +1,27 @@ -import type { ServerContext } from '@modelcontextprotocol/server' -import type { JsonSchemaConverter } from '@orpc/json-schema' -import type { ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' +import type { ServerContext } from '@modelcontextprotocol/server' import { createMcpHandler } from '@modelcontextprotocol/server' import { ORPCError } from '@orpc/client' import { asyncIteratorObject, oc, type } from '@orpc/contract' +import type { JsonSchemaConverter } from '@orpc/json-schema' import { SmartCoercionHandlerPlugin } from '@orpc/json-schema' import { openapi, OpenAPISerializer } from '@orpc/openapi' import { implement, os } from '@orpc/server' +import type { ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' import { ResponseHeadersHandlerPlugin } from '@orpc/server/plugins' import { ZodToJsonSchemaConverter } from '@orpc/zod' import { z } from 'zod' -import { createTestClient, createTestServer, PROTOCOL_ERAS } from './__tests__/utils' -import { mcp } from './meta' -import { McpRegistrar } from './registrar' + +import { createTestClient, createTestServer, PROTOCOL_ERAS } from './__tests__/utils.ts' +import { mcp } from './meta.ts' +import { McpRegistrar } from './registrar.ts' const planetSchema = z.object({ id: z.number(), name: z.string() }) /** * Asserts a tool call failed with a tool execution error, and returns the error JSON the model reads. */ -function parseToolError(result: { isError?: unknown, content?: unknown }): any { +function parseToolError(result: { isError?: unknown; content?: unknown }): any { expect(result.isError).toBe(true) return JSON.parse((result.content as any)[0].text) } @@ -28,10 +29,21 @@ function parseToolError(result: { isError?: unknown, content?: unknown }): any { const router = { planet: { list: os - .meta(openapi({ method: 'GET', summary: 'List planets', description: 'List planets, newest first' })) + .meta( + openapi({ + method: 'GET', + summary: 'List planets', + description: 'List planets, newest first', + }), + ) .input(z.object({ limit: z.number().optional() })) .output(z.array(planetSchema)) - .handler(({ input }) => [{ id: 1, name: 'Earth' }, { id: 2, name: 'Mars' }].slice(0, input.limit)), + .handler(({ input }) => + [ + { id: 1, name: 'Earth' }, + { id: 2, name: 'Mars' }, + ].slice(0, input.limit), + ), find: os .errors({ NOT_FOUND: { message: 'Planet not found', data: z.object({ id: z.number() }) } }) .input(z.object({ id: z.number() })) @@ -43,9 +55,7 @@ const router = { return { id: 1, name: 'Earth', discoveredAt: new Date('2020-01-01T00:00:00.000Z') } }), - describe: os - .input(z.object({ id: z.number() })) - .handler(() => 'A blue planet'), + describe: os.input(z.object({ id: z.number() })).handler(() => 'A blue planet'), }, } @@ -55,28 +65,38 @@ describe.each(PROTOCOL_ERAS)('mcpRegistrar - %s protocol', (era) => { const { tools } = await client.listTools() - expect(tools.map(tool => tool.name)).toEqual(['planet_list', 'planet_find', 'planet_describe']) - - expect(tools[0]).toEqual(expect.objectContaining({ - name: 'planet_list', - title: 'List planets', - description: 'List planets, newest first', - annotations: { readOnlyHint: true }, - inputSchema: expect.objectContaining({ - type: 'object', - properties: { limit: { type: 'number' } }, + expect(tools.map((tool) => tool.name)).toEqual([ + 'planet_list', + 'planet_find', + 'planet_describe', + ]) + + expect(tools[0]).toEqual( + expect.objectContaining({ + name: 'planet_list', + title: 'List planets', + description: 'List planets, newest first', + annotations: { readOnlyHint: true }, + inputSchema: expect.objectContaining({ + type: 'object', + properties: { limit: { type: 'number' } }, + }), }), - })) + ) const planetsJsonSchema = expect.objectContaining({ type: 'array', - items: expect.objectContaining({ properties: { id: { type: 'number' }, name: { type: 'string' } } }), + items: expect.objectContaining({ + properties: { id: { type: 'number' }, name: { type: 'string' } }, + }), }) // the 2025 protocol requires object output schemas, so the MCP SDK nests other schemas under `result` - expect(tools[0]!.outputSchema).toEqual(era === 'legacy' - ? { type: 'object', properties: { result: planetsJsonSchema }, required: ['result'] } - : planetsJsonSchema) + expect(tools[0]!.outputSchema).toEqual( + era === 'legacy' + ? { type: 'object', properties: { result: planetsJsonSchema }, required: ['result'] } + : planetsJsonSchema, + ) expect(tools[2]!.outputSchema).toBeUndefined() }) @@ -154,18 +174,29 @@ describe.each(PROTOCOL_ERAS)('mcpRegistrar - %s protocol', (era) => { const client = await createTestClient(era, new McpRegistrar({ deploy })) const { tools } = await client.listTools() - expect(tools[0]!.outputSchema).toEqual(expect.objectContaining({ - type: 'object', - properties: { status: { type: 'string' } }, - })) + expect(tools[0]!.outputSchema).toEqual( + expect.objectContaining({ + type: 'object', + properties: { status: { type: 'string' } }, + }), + ) const onprogress = vi.fn() - const result = await client.callTool({ name: 'deploy', arguments: { app: 'web' } }, { onprogress }) + const result = await client.callTool( + { name: 'deploy', arguments: { app: 'web' } }, + { onprogress }, + ) expect(result.structuredContent).toEqual({ status: 'deployed web' }) expect(onprogress).toHaveBeenCalledTimes(2) - expect(onprogress).toHaveBeenNthCalledWith(1, expect.objectContaining({ progress: 1, message: '{"status":"building"}' })) - expect(onprogress).toHaveBeenNthCalledWith(2, expect.objectContaining({ progress: 2, message: '{"status":"deployed web"}' })) + expect(onprogress).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ progress: 1, message: '{"status":"building"}' }), + ) + expect(onprogress).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ progress: 2, message: '{"status":"deployed web"}' }), + ) }) }) @@ -180,21 +211,40 @@ describe('mcpRegistrar', () => { describe('tool definition', () => { it('inherits name, title, description, and annotations from OpenAPI meta', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - get: os.meta(openapi({ method: 'GET', operationId: 'getPlanet', summary: 'Get a planet' })).handler(() => {}), - head: os.meta(openapi({ method: 'HEAD', description: 'Check a planet' })).handler(() => {}), - query: os.meta(openapi({ method: 'QUERY' })).handler(() => {}), - put: os.meta(openapi({ method: 'PUT' })).handler(() => {}), - delete: os.meta(openapi({ method: 'DELETE' })).handler(() => {}), - post: os.meta(openapi({ method: 'POST' })).handler(() => {}), - patch: os.meta(openapi({ method: 'PATCH' })).handler(() => {}), - none: os.handler(() => {}), - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + get: os + .meta(openapi({ method: 'GET', operationId: 'getPlanet', summary: 'Get a planet' })) + .handler(() => {}), + head: os + .meta(openapi({ method: 'HEAD', description: 'Check a planet' })) + .handler(() => {}), + query: os.meta(openapi({ method: 'QUERY' })).handler(() => {}), + put: os.meta(openapi({ method: 'PUT' })).handler(() => {}), + delete: os.meta(openapi({ method: 'DELETE' })).handler(() => {}), + post: os.meta(openapi({ method: 'POST' })).handler(() => {}), + patch: os.meta(openapi({ method: 'PATCH' })).handler(() => {}), + none: os.handler(() => {}), + }), + ) const { tools } = await client.listTools() - expect(tools.map(({ name, title, description, annotations }) => ({ name, title, description, annotations }))).toEqual([ - { name: 'getPlanet', title: 'Get a planet', description: 'Get a planet', annotations: { readOnlyHint: true } }, + expect( + tools.map(({ name, title, description, annotations }) => ({ + name, + title, + description, + annotations, + })), + ).toEqual([ + { + name: 'getPlanet', + title: 'Get a planet', + description: 'Get a planet', + annotations: { readOnlyHint: true }, + }, { name: 'head', description: 'Check a planet', annotations: { readOnlyHint: true } }, { name: 'query', annotations: { readOnlyHint: true } }, { name: 'put', annotations: { idempotentHint: true } }, @@ -207,29 +257,40 @@ describe('mcpRegistrar', () => { it('prefers mcp.tool meta over OpenAPI meta', async () => { const procedure = os - .meta(openapi({ method: 'GET', operationId: 'getPlanet', summary: 'Get a planet', description: 'OpenAPI description' })) - .meta(mcp.tool({ - name: 'get_planet', - title: 'Planet', - description: 'MCP description', - annotations: { openWorldHint: false }, - icons: [{ src: 'https://example.com/planet.png', mimeType: 'image/png' }], - _meta: { source: 'planets' }, - })) + .meta( + openapi({ + method: 'GET', + operationId: 'getPlanet', + summary: 'Get a planet', + description: 'OpenAPI description', + }), + ) + .meta( + mcp.tool({ + name: 'get_planet', + title: 'Planet', + description: 'MCP description', + annotations: { openWorldHint: false }, + icons: [{ src: 'https://example.com/planet.png', mimeType: 'image/png' }], + _meta: { source: 'planets' }, + }), + ) .handler(() => {}) const client = await createTestClient('legacy', new McpRegistrar({ procedure })) const { tools } = await client.listTools() - expect(tools).toEqual([expect.objectContaining({ - name: 'get_planet', - title: 'Planet', - description: 'MCP description', - annotations: { readOnlyHint: true, openWorldHint: false }, - icons: [{ src: 'https://example.com/planet.png', mimeType: 'image/png' }], - _meta: { source: 'planets' }, - })]) + expect(tools).toEqual([ + expect.objectContaining({ + name: 'get_planet', + title: 'Planet', + description: 'MCP description', + annotations: { readOnlyHint: true, openWorldHint: false }, + icons: [{ src: 'https://example.com/planet.png', mimeType: 'image/png' }], + _meta: { source: 'planets' }, + }), + ]) }) it('lets mcp.tool annotations override inferred hints', async () => { @@ -246,30 +307,37 @@ describe('mcpRegistrar', () => { }) it('names nested and lazy procedures by their path', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - planet: { - moon: { list: os.handler(() => []) }, - lazy: os.lazy(() => Promise.resolve({ default: { find: os.handler(() => ({})) } })), - }, - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + planet: { + moon: { list: os.handler(() => []) }, + lazy: os.lazy(() => Promise.resolve({ default: { find: os.handler(() => ({})) } })), + }, + }), + ) const { tools } = await client.listTools() - expect(tools.map(tool => tool.name)).toEqual(['planet_moon_list', 'planet_lazy_find']) + expect(tools.map((tool) => tool.name)).toEqual(['planet_moon_list', 'planet_lazy_find']) }) it('throws when two procedures share a tool name', async () => { const server = createTestServer() - await expect(new McpRegistrar({ - a: { b: os.handler(() => {}) }, - a_b: os.handler(() => {}), - }).register(server)).rejects.toThrow('[MCP] Procedures at "a.b" and "a_b" share the tool name "a_b".') - - await expect(new McpRegistrar({ - a: os.meta(openapi({ operationId: 'same' })).handler(() => {}), - b: os.meta(mcp.tool({ name: 'same' })).handler(() => {}), - }).register(server)).rejects.toThrow('[MCP] Procedures at "a" and "b" share the tool name "same".') + await expect( + new McpRegistrar({ + a: { b: os.handler(() => {}) }, + a_b: os.handler(() => {}), + }).register(server), + ).rejects.toThrow('[MCP] Procedures at "a.b" and "a_b" share the tool name "a_b".') + + await expect( + new McpRegistrar({ + a: os.meta(openapi({ operationId: 'same' })).handler(() => {}), + b: os.meta(mcp.tool({ name: 'same' })).handler(() => {}), + }).register(server), + ).rejects.toThrow('[MCP] Procedures at "a" and "b" share the tool name "same".') }) it('throws when a tool name cannot be derived', async () => { @@ -279,7 +347,9 @@ describe('mcpRegistrar', () => { '[MCP] Procedure at "(root)" has no name. Set one with mcp.tool({ name }).', ) - const { tools } = await new McpRegistrar(os.meta(mcp.tool({ name: 'root' })).handler(() => {})).register(server) + const { tools } = await new McpRegistrar( + os.meta(mcp.tool({ name: 'root' })).handler(() => {}), + ).register(server) expect(Object.keys(tools)).toEqual(['root']) }) @@ -287,10 +357,12 @@ describe('mcpRegistrar', () => { const server = createTestServer() const registerTool = vi.spyOn(server, 'registerTool') - await expect(new McpRegistrar({ - a: os.handler(() => {}), - b: os.meta(mcp.tool({ name: 'a' })).handler(() => {}), - }).register(server)).rejects.toThrow() + await expect( + new McpRegistrar({ + a: os.handler(() => {}), + b: os.meta(mcp.tool({ name: 'a' })).handler(() => {}), + }).register(server), + ).rejects.toThrow() expect(registerTool).not.toHaveBeenCalled() }) @@ -302,7 +374,7 @@ describe('mcpRegistrar', () => { const { tools } = await client.listTools() - expect(tools.map(tool => tool.name)).toEqual(['planet_list', 'planet_describe']) + expect(tools.map((tool) => tool.name)).toEqual(['planet_list', 'planet_describe']) expect(filter).toHaveBeenCalledWith(router.planet.find, ['planet', 'find']) }) }) @@ -310,7 +382,10 @@ describe('mcpRegistrar', () => { describe('input', () => { it('advertises an empty object schema when the procedure has no input schema', async () => { const handler = vi.fn(() => 'ok') - const client = await createTestClient('legacy', new McpRegistrar({ ping: os.handler(handler) })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ ping: os.handler(handler) }), + ) const { tools } = await client.listTools() expect(tools[0]!.inputSchema).toEqual({ type: 'object', properties: {} }) @@ -320,22 +395,36 @@ describe('mcpRegistrar', () => { }) it('passes no input for empty arguments when the input accepts no object', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - void: os.input(z.void()).handler(({ input }) => input === undefined), - undefined: os.input(z.undefined()).handler(({ input }) => input === undefined), - defaults: os.input(z.object({ limit: z.number().default(10) })).handler(({ input }) => input.limit), - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + void: os.input(z.void()).handler(({ input }) => input === undefined), + undefined: os.input(z.undefined()).handler(({ input }) => input === undefined), + defaults: os + .input(z.object({ limit: z.number().default(10) })) + .handler(({ input }) => input.limit), + }), + ) - expect((await client.callTool({ name: 'void' })).content).toEqual([{ type: 'text', text: 'true' }]) - expect((await client.callTool({ name: 'undefined', arguments: {} })).content).toEqual([{ type: 'text', text: 'true' }]) + expect((await client.callTool({ name: 'void' })).content).toEqual([ + { type: 'text', text: 'true' }, + ]) + expect((await client.callTool({ name: 'undefined', arguments: {} })).content).toEqual([ + { type: 'text', text: 'true' }, + ]) // an object input still receives the empty arguments, so its defaults apply - expect((await client.callTool({ name: 'defaults' })).content).toEqual([{ type: 'text', text: '10' }]) + expect((await client.callTool({ name: 'defaults' })).content).toEqual([ + { type: 'text', text: '10' }, + ]) }) it('advertises an object schema when the input has no JSON Schema', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - ping: os.input(type<{ name: string }>()).handler(({ input }) => input.name), - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + ping: os.input(type<{ name: string }>()).handler(({ input }) => input.name), + }), + ) const { tools } = await client.listTools() expect(tools[0]!.inputSchema).toEqual({ type: 'object' }) @@ -361,17 +450,27 @@ describe('mcpRegistrar', () => { ], }) - const result = await client.callTool({ name: 'procedure', arguments: { id: 1, name: 'Earth' } }) + const result = await client.callTool({ + name: 'procedure', + arguments: { id: 1, name: 'Earth' }, + }) expect(result.structuredContent).toEqual({ id: 1, name: 'Earth' }) }) it('nests a non-object input under `input`, since tool arguments must be an object', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - required: os.input(z.string()).handler(({ input }) => `required ${input}`), - optional: os.input(z.string().optional()).handler(({ input }) => `optional ${input}`), - union: os.input(z.union([z.object({ id: z.number() }), z.number()])).handler(({ input }) => input), - intersection: os.input(z.intersection(z.object({ id: z.number() }), z.any())).handler(({ input }) => input), - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + required: os.input(z.string()).handler(({ input }) => `required ${input}`), + optional: os.input(z.string().optional()).handler(({ input }) => `optional ${input}`), + union: os + .input(z.union([z.object({ id: z.number() }), z.number()])) + .handler(({ input }) => input), + intersection: os + .input(z.intersection(z.object({ id: z.number() }), z.any())) + .handler(({ input }) => input), + }), + ) const { tools } = await client.listTools() @@ -388,18 +487,23 @@ describe('mcpRegistrar', () => { expect(tools[2]!.inputSchema).toEqual({ type: 'object', - properties: { input: { anyOf: [expect.objectContaining({ type: 'object' }), { type: 'number' }] } }, + properties: { + input: { anyOf: [expect.objectContaining({ type: 'object' }), { type: 'number' }] }, + }, required: ['input'], }) - expect((await client.callTool({ name: 'required', arguments: { input: 'Earth' } })).content) - .toEqual([{ type: 'text', text: 'required Earth' }]) + expect( + (await client.callTool({ name: 'required', arguments: { input: 'Earth' } })).content, + ).toEqual([{ type: 'text', text: 'required Earth' }]) - expect((await client.callTool({ name: 'optional', arguments: {} })).content) - .toEqual([{ type: 'text', text: 'optional undefined' }]) + expect((await client.callTool({ name: 'optional', arguments: {} })).content).toEqual([ + { type: 'text', text: 'optional undefined' }, + ]) - expect((await client.callTool({ name: 'union', arguments: { input: 1 } })).content) - .toEqual([{ type: 'text', text: '1' }]) + expect((await client.callTool({ name: 'union', arguments: { input: 1 } })).content).toEqual([ + { type: 'text', text: '1' }, + ]) // an object-only input stays unwrapped, even when composed with an unconstrained schema expect(tools[3]!.inputSchema).toEqual({ @@ -407,17 +511,26 @@ describe('mcpRegistrar', () => { allOf: [expect.objectContaining({ type: 'object' }), {}], }) - expect((await client.callTool({ name: 'intersection', arguments: { id: 1 } })).structuredContent) - .toEqual({ id: 1 }) + expect( + (await client.callTool({ name: 'intersection', arguments: { id: 1 } })).structuredContent, + ).toEqual({ id: 1 }) }) it('keeps defs reachable when nesting a recursive input', async () => { - interface Category { name: string, children: Category[] } - const category: z.ZodType = z.lazy(() => z.object({ name: z.string(), children: z.array(category) })) + interface Category { + name: string + children: Category[] + } + const category: z.ZodType = z.lazy(() => + z.object({ name: z.string(), children: z.array(category) }), + ) - const client = await createTestClient('legacy', new McpRegistrar({ - list: os.input(z.array(category)).handler(({ input }) => input.length), - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + list: os.input(z.array(category)).handler(({ input }) => input.length), + }), + ) const { tools } = await client.listTools() const inputSchema = tools[0]!.inputSchema as any @@ -428,25 +541,37 @@ describe('mcpRegistrar', () => { expect(JSON.stringify(inputSchema.properties.input)).toContain('#/$defs/') expect(Object.keys(inputSchema.$defs)).not.toHaveLength(0) - const result = await client.callTool({ name: 'list', arguments: { input: [{ name: 'Rocky', children: [] }] } }) + const result = await client.callTool({ + name: 'list', + arguments: { input: [{ name: 'Rocky', children: [] }] }, + }) expect(result.content).toEqual([{ type: 'text', text: '1' }]) }) it('uses JSON Schema converters', async () => { const converter: JsonSchemaConverter = { - condition: schema => schema !== undefined && schema['~standard'].vendor === 'custom', + condition: (schema) => schema !== undefined && schema['~standard'].vendor === 'custom', convert: () => [{ type: 'object', properties: { custom: { type: 'boolean' } } }, false], } const custom = type<{ custom: boolean }>() ;(custom['~standard'] as any).vendor = 'custom' - const client = await createTestClient('legacy', new McpRegistrar({ - procedure: os.input(custom).handler(() => {}), - }, { converters: [converter] })) + const client = await createTestClient( + 'legacy', + new McpRegistrar( + { + procedure: os.input(custom).handler(() => {}), + }, + { converters: [converter] }, + ), + ) const { tools } = await client.listTools() - expect(tools[0]!.inputSchema).toEqual({ type: 'object', properties: { custom: { type: 'boolean' } } }) + expect(tools[0]!.inputSchema).toEqual({ + type: 'object', + properties: { custom: { type: 'boolean' } }, + }) }) it('coerces arguments with the Smart Coercion Plugin', async () => { @@ -455,18 +580,26 @@ describe('mcpRegistrar', () => { .handler(({ input }) => ({ year: input.at.getUTCFullYear(), count: input.count })) const converters = [new ZodToJsonSchemaConverter()] - const client = await createTestClient('legacy', new McpRegistrar({ procedure }, { - converters, - plugins: [new SmartCoercionHandlerPlugin({ converters })], - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar( + { procedure }, + { + converters, + plugins: [new SmartCoercionHandlerPlugin({ converters })], + }, + ), + ) const { tools } = await client.listTools() - expect(tools[0]!.inputSchema).toEqual(expect.objectContaining({ - properties: { - at: { 'type': 'string', 'format': 'date-time', 'x-native-type': 'date' }, - count: { type: 'number' }, - }, - })) + expect(tools[0]!.inputSchema).toEqual( + expect.objectContaining({ + properties: { + at: { 'type': 'string', 'format': 'date-time', 'x-native-type': 'date' }, + count: { type: 'number' }, + }, + }), + ) const args = { at: '2020-01-01T00:00:00.000Z', count: '2' } @@ -474,7 +607,10 @@ describe('mcpRegistrar', () => { expect(result.structuredContent).toEqual({ year: 2020, count: 2 }) // arguments are not coerced without the plugin - const uncoerced = await createTestClient('legacy', new McpRegistrar({ procedure }, { converters })) + const uncoerced = await createTestClient( + 'legacy', + new McpRegistrar({ procedure }, { converters }), + ) const failed = await uncoerced.callTool({ name: 'procedure', arguments: args }) expect(failed.isError).toBe(true) @@ -486,19 +622,30 @@ describe('mcpRegistrar', () => { .handler(({ input }) => input.big + 1n) const converters = [new ZodToJsonSchemaConverter()] - const client = await createTestClient('legacy', new McpRegistrar({ procedure, date: os.input(z.date()).handler(({ input }) => input.getUTCFullYear()) }, { - converters, - plugins: [new SmartCoercionHandlerPlugin({ converters, maxBigIntDigits: 2 })], - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar( + { procedure, date: os.input(z.date()).handler(({ input }) => input.getUTCFullYear()) }, + { + converters, + plugins: [new SmartCoercionHandlerPlugin({ converters, maxBigIntDigits: 2 })], + }, + ), + ) - expect((await client.callTool({ name: 'procedure', arguments: { big: '12' } })).content) - .toEqual([{ type: 'text', text: '13' }]) + expect( + (await client.callTool({ name: 'procedure', arguments: { big: '12' } })).content, + ).toEqual([{ type: 'text', text: '13' }]) - expect((await client.callTool({ name: 'procedure', arguments: { big: '123' } })).isError).toBe(true) + expect( + (await client.callTool({ name: 'procedure', arguments: { big: '123' } })).isError, + ).toBe(true) // coerced after unwrapping from `input` - expect((await client.callTool({ name: 'date', arguments: { input: '2020-01-01T00:00:00.000Z' } })).content) - .toEqual([{ type: 'text', text: '2020' }]) + expect( + (await client.callTool({ name: 'date', arguments: { input: '2020-01-01T00:00:00.000Z' } })) + .content, + ).toEqual([{ type: 'text', text: '2020' }]) }) }) @@ -535,25 +682,43 @@ describe('mcpRegistrar', () => { } const serializer = new OpenAPISerializer({ - handlers: { planet: { condition: data => data instanceof Planet, serialize: (planet: Planet) => `planet:${planet.name}` } }, + handlers: { + planet: { + condition: (data) => data instanceof Planet, + serialize: (planet: Planet) => `planet:${planet.name}`, + }, + }, }) - const client = await createTestClient('legacy', new McpRegistrar({ - find: os.handler(() => ({ planet: new Planet('Earth') })), - fail: os.handler(() => { - throw new ORPCError('CONFLICT', { data: { planet: new Planet('Mars') } }) - }), - }, { serializer })) + const client = await createTestClient( + 'legacy', + new McpRegistrar( + { + find: os.handler(() => ({ planet: new Planet('Earth') })), + fail: os.handler(() => { + throw new ORPCError('CONFLICT', { data: { planet: new Planet('Mars') } }) + }), + }, + { serializer }, + ), + ) - expect((await client.callTool({ name: 'find' })).structuredContent).toEqual({ planet: 'planet:Earth' }) - expect(parseToolError(await client.callTool({ name: 'fail' })).data).toEqual({ planet: 'planet:Mars' }) + expect((await client.callTool({ name: 'find' })).structuredContent).toEqual({ + planet: 'planet:Earth', + }) + expect(parseToolError(await client.callTool({ name: 'fail' })).data).toEqual({ + planet: 'planet:Mars', + }) }) it('returns no content for undefined outputs', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - withoutSchema: os.handler(() => {}), - withSchema: os.output(z.object({ id: z.number() }).optional()).handler(() => undefined), - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + withoutSchema: os.handler(() => {}), + withSchema: os.output(z.object({ id: z.number() }).optional()).handler(() => undefined), + }), + ) const { tools } = await client.listTools() // an output schema requires structured content in every result @@ -566,45 +731,73 @@ describe('mcpRegistrar', () => { it('returns blob outputs as image, audio, or embedded resource content', async () => { const bytes = new Uint8Array([1, 2, 3]) - const client = await createTestClient('legacy', new McpRegistrar({ - image: os.output(z.file()).handler(() => new File([bytes], 'planet.png', { type: 'image/png' })), - audio: os.handler(() => new Blob([bytes], { type: 'audio/wav' })), - file: os.handler(() => new File([bytes], 'planet report.pdf', { type: 'application/pdf' })), - blob: os.handler(() => new Blob([bytes])), - stream: os.handler(() => new Blob([bytes], { type: 'text/plain' }).stream()), - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + image: os + .output(z.file()) + .handler(() => new File([bytes], 'planet.png', { type: 'image/png' })), + audio: os.handler(() => new Blob([bytes], { type: 'audio/wav' })), + file: os.handler( + () => new File([bytes], 'planet report.pdf', { type: 'application/pdf' }), + ), + blob: os.handler(() => new Blob([bytes])), + stream: os.handler(() => new Blob([bytes], { type: 'text/plain' }).stream()), + }), + ) const { tools } = await client.listTools() expect(tools[0]!.outputSchema).toBeUndefined() - expect((await client.callTool({ name: 'image' })).content) - .toEqual([{ type: 'image', data: 'AQID', mimeType: 'image/png' }]) + expect((await client.callTool({ name: 'image' })).content).toEqual([ + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + ]) - expect((await client.callTool({ name: 'audio' })).content) - .toEqual([{ type: 'audio', data: 'AQID', mimeType: 'audio/wav' }]) + expect((await client.callTool({ name: 'audio' })).content).toEqual([ + { type: 'audio', data: 'AQID', mimeType: 'audio/wav' }, + ]) - expect((await client.callTool({ name: 'file' })).content) - .toEqual([{ type: 'resource', resource: { uri: 'file:///planet%20report.pdf', mimeType: 'application/pdf', blob: 'AQID' } }]) + expect((await client.callTool({ name: 'file' })).content).toEqual([ + { + type: 'resource', + resource: { + uri: 'file:///planet%20report.pdf', + mimeType: 'application/pdf', + blob: 'AQID', + }, + }, + ]) - expect((await client.callTool({ name: 'blob' })).content) - .toEqual([{ type: 'resource', resource: { uri: 'file:///blob', mimeType: 'application/octet-stream', blob: 'AQID' } }]) + expect((await client.callTool({ name: 'blob' })).content).toEqual([ + { + type: 'resource', + resource: { uri: 'file:///blob', mimeType: 'application/octet-stream', blob: 'AQID' }, + }, + ]) - expect((await client.callTool({ name: 'stream' })).content) - .toEqual([{ type: 'resource', resource: { uri: 'file:///blob', mimeType: 'application/octet-stream', blob: 'AQID' } }]) + expect((await client.callTool({ name: 'stream' })).content).toEqual([ + { + type: 'resource', + resource: { uri: 'file:///blob', mimeType: 'application/octet-stream', blob: 'AQID' }, + }, + ]) }) it('also returns nested blob outputs as embedded resources named by their paths', async () => { const bytes = new Uint8Array([1, 2, 3]) - const client = await createTestClient('legacy', new McpRegistrar({ - report: os - .output(z.object({ title: z.string(), file: z.file(), covers: z.array(z.file()) })) - .handler(() => ({ - title: 'Q3', - file: new File([bytes], 'q3 report.pdf', { type: 'application/pdf' }), - covers: [new File([bytes], '', { type: 'image/png' })], - })), - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + report: os + .output(z.object({ title: z.string(), file: z.file(), covers: z.array(z.file()) })) + .handler(() => ({ + title: 'Q3', + file: new File([bytes], 'q3 report.pdf', { type: 'application/pdf' }), + covers: [new File([bytes], '', { type: 'image/png' })], + })), + }), + ) const { tools } = await client.listTools() expect(tools[0]!.outputSchema).toBeUndefined() @@ -612,8 +805,18 @@ describe('mcpRegistrar', () => { expect(await client.callTool({ name: 'report' })).toEqual({ content: [ { type: 'text', text: '{"title":"Q3","file":{},"covers":[{}]}' }, - { type: 'resource', resource: { uri: 'file:///file/q3%20report.pdf', mimeType: 'application/pdf', blob: 'AQID' } }, - { type: 'resource', resource: { uri: 'file:///covers/0', mimeType: 'image/png', blob: 'AQID' } }, + { + type: 'resource', + resource: { + uri: 'file:///file/q3%20report.pdf', + mimeType: 'application/pdf', + blob: 'AQID', + }, + }, + { + type: 'resource', + resource: { uri: 'file:///covers/0', mimeType: 'image/png', blob: 'AQID' }, + }, ], // the in-memory transport keeps the files, which other transports serialize as empty objects structuredContent: { title: 'Q3', file: expect.any(File), covers: [expect.any(File)] }, @@ -621,40 +824,55 @@ describe('mcpRegistrar', () => { }) it('types stream outputs with the content-type response header', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - image: os.$context().handler(({ context }) => { - context.resHeaders?.set('content-type', 'image/png') - return new Blob([new Uint8Array([1, 2, 3])]).stream() - }), - }, { plugins: [new ResponseHeadersHandlerPlugin()] })) + const client = await createTestClient( + 'legacy', + new McpRegistrar( + { + image: os.$context().handler(({ context }) => { + context.resHeaders?.set('content-type', 'image/png') + return new Blob([new Uint8Array([1, 2, 3])]).stream() + }), + }, + { plugins: [new ResponseHeadersHandlerPlugin()] }, + ), + ) - expect((await client.callTool({ name: 'image' })).content) - .toEqual([{ type: 'image', data: 'AQID', mimeType: 'image/png' }]) + expect((await client.callTool({ name: 'image' })).content).toEqual([ + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + ]) }) it('returns the last event of async generator handlers', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - stream: os.handler(async function* () { - yield 'first' - yield 'last' + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + stream: os.handler(async function* () { + yield 'first' + yield 'last' + }), + empty: os.handler(async function* () {}), }), - empty: os.handler(async function* () {}), - })) + ) const { tools } = await client.listTools() expect(tools[0]!.outputSchema).toBeUndefined() - expect((await client.callTool({ name: 'stream' })).content).toEqual([{ type: 'text', text: 'last' }]) + expect((await client.callTool({ name: 'stream' })).content).toEqual([ + { type: 'text', text: 'last' }, + ]) expect((await client.callTool({ name: 'empty' })).content).toEqual([]) }) it('reports blob events as progress without a message', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - render: os.handler(async function* () { - yield new Blob([new Uint8Array([1])], { type: 'image/png' }) - yield new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + render: os.handler(async function* () { + yield new Blob([new Uint8Array([1])], { type: 'image/png' }) + yield new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }) + }), }), - })) + ) const onprogress = vi.fn() const result = await client.callTool({ name: 'render' }, { onprogress }) @@ -665,16 +883,21 @@ describe('mcpRegistrar', () => { }) it('reports errors thrown while streaming', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - stream: os.handler(async function* () { - yield 'first' - throw new ORPCError('CONFLICT', { message: 'Deploy in progress' }) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + stream: os.handler(async function* () { + yield 'first' + throw new ORPCError('CONFLICT', { message: 'Deploy in progress' }) + }), }), - })) + ) const result = await client.callTool({ name: 'stream' }) - expect(parseToolError(result)).toEqual(expect.objectContaining({ code: 'CONFLICT', message: 'Deploy in progress' })) + expect(parseToolError(result)).toEqual( + expect.objectContaining({ code: 'CONFLICT', message: 'Deploy in progress' }), + ) }) it('stops consuming async iterators once the call is aborted', async () => { @@ -687,8 +910,7 @@ describe('mcpRegistrar', () => { abortController.abort() yield 'second' yield 'third' - } - finally { + } finally { cleanup() } }) @@ -708,11 +930,14 @@ describe('mcpRegistrar', () => { describe('errors', () => { it('hides details of errors that are not ORPCErrors', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - procedure: os.handler(() => { - throw new Error('database password is hunter2') + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + procedure: os.handler(() => { + throw new Error('database password is hunter2') + }), }), - })) + ) const result = await client.callTool({ name: 'procedure' }) @@ -724,34 +949,44 @@ describe('mcpRegistrar', () => { }) it('reports errors with custom codes as tool execution errors', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - procedure: os.handler(() => { - throw new ORPCError('PLANET_GONE', { message: 'The planet is gone' }) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + procedure: os.handler(() => { + throw new ORPCError('PLANET_GONE', { message: 'The planet is gone' }) + }), }), - })) + ) const result = await client.callTool({ name: 'procedure' }) - expect(parseToolError(result)).toEqual(expect.objectContaining({ code: 'PLANET_GONE', message: 'The planet is gone' })) + expect(parseToolError(result)).toEqual( + expect.objectContaining({ code: 'PLANET_GONE', message: 'The planet is gone' }), + ) }) it('also returns blobs nested in error data as embedded resources named by their paths', async () => { const file = new File([new Uint8Array([1, 2, 3])], 'log.txt', { type: 'text/plain' }) - const client = await createTestClient('legacy', new McpRegistrar({ - procedure: os.handler(() => { - throw new ORPCError('CONFLICT', { data: { log: file } }) - }), - stream: os.handler(async function* () { - yield 'first' - throw new ORPCError('CONFLICT', { data: { log: file } }) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + procedure: os.handler(() => { + throw new ORPCError('CONFLICT', { data: { log: file } }) + }), + stream: os.handler(async function* () { + yield 'first' + throw new ORPCError('CONFLICT', { data: { log: file } }) + }), }), - })) + ) for (const name of ['procedure', 'stream']) { const result = await client.callTool({ name }) - expect(parseToolError(result)).toEqual(expect.objectContaining({ code: 'CONFLICT', data: { log: {} } })) + expect(parseToolError(result)).toEqual( + expect.objectContaining({ code: 'CONFLICT', data: { log: {} } }), + ) expect((result.content as any)[1]).toEqual({ type: 'resource', resource: { uri: 'file:///data/log/log.txt', mimeType: 'text/plain', blob: 'AQID' }, @@ -760,16 +995,23 @@ describe('mcpRegistrar', () => { }) it('reports output validation errors as internal errors', async () => { - const client = await createTestClient('legacy', new McpRegistrar({ - procedure: os.output(z.object({ id: z.number() })).handler(() => ({ id: 'invalid' }) as any), - })) + const client = await createTestClient( + 'legacy', + new McpRegistrar({ + procedure: os + .output(z.object({ id: z.number() })) + .handler(() => ({ id: 'invalid' }) as any), + }), + ) const result = await client.callTool({ name: 'procedure' }) - expect(parseToolError(result)).toEqual(expect.objectContaining({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Output validation failed', - })) + expect(parseToolError(result)).toEqual( + expect.objectContaining({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Output validation failed', + }), + ) }) }) @@ -779,7 +1021,9 @@ describe('mcpRegistrar', () => { .handler(({ context }) => context.auth ?? 'anonymous') it('resolves the initial context from the MCP request context', async () => { - const context = vi.fn((ctx: ServerContext) => ({ auth: ctx.http?.req?.headers.get('authorization') ?? undefined })) + const context = vi.fn((ctx: ServerContext) => ({ + auth: ctx.http?.req?.headers.get('authorization') ?? undefined, + })) const client = await createTestClient('modern', new McpRegistrar({ procedure }), { context, @@ -789,51 +1033,75 @@ describe('mcpRegistrar', () => { const result = await client.callTool({ name: 'procedure' }) expect(result.content).toEqual([{ type: 'text', text: 'Bearer token' }]) - expect(context).toHaveBeenCalledWith(expect.objectContaining({ - mcpReq: expect.objectContaining({ method: 'tools/call', signal: expect.any(AbortSignal) }), - })) + expect(context).toHaveBeenCalledWith( + expect.objectContaining({ + mcpReq: expect.objectContaining({ + method: 'tools/call', + signal: expect.any(AbortSignal), + }), + }), + ) }) it('uses the context of the server a tool is registered on', async () => { const registrar = new McpRegistrar({ procedure }) - const { tools: first } = await registrar.register(createTestServer(), { context: { auth: 'first' } }) - const { tools: second } = await registrar.register(createTestServer(), { context: async () => ({ auth: 'second' }) }) + const { tools: first } = await registrar.register(createTestServer(), { + context: { auth: 'first' }, + }) + const { tools: second } = await registrar.register(createTestServer(), { + context: async () => ({ auth: 'second' }), + }) const { tools: anonymous } = await registrar.register(createTestServer()) const ctx = { mcpReq: { signal: new AbortController().signal } } as any - expect(await first.procedure!.handler({} as any, ctx)).toEqual(expect.objectContaining({ content: [{ type: 'text', text: 'first' }] })) - expect(await second.procedure!.handler({} as any, ctx)).toEqual(expect.objectContaining({ content: [{ type: 'text', text: 'second' }] })) - expect(await anonymous.procedure!.handler({} as any, ctx)).toEqual(expect.objectContaining({ content: [{ type: 'text', text: 'anonymous' }] })) + expect(await first.procedure!.handler({} as any, ctx)).toEqual( + expect.objectContaining({ content: [{ type: 'text', text: 'first' }] }), + ) + expect(await second.procedure!.handler({} as any, ctx)).toEqual( + expect.objectContaining({ content: [{ type: 'text', text: 'second' }] }), + ) + expect(await anonymous.procedure!.handler({} as any, ctx)).toEqual( + expect.objectContaining({ content: [{ type: 'text', text: 'anonymous' }] }), + ) }) - it.each(['legacy', 'auto'] as const)('provides per-request context from the createMcpHandler factory (%s negotiation)', async (mode) => { - const registrar = new McpRegistrar({ procedure }) - - const handler = createMcpHandler(async ({ authInfo }) => { - const server = createTestServer() - await registrar.register(server, { context: { auth: authInfo?.extra?.user as string } }) - return server - }) - onTestFinished(() => handler.close()) - - const callAs = async (user: string) => { - const client = new Client({ name: 'test-client', version: '1.0.0' }, { versionNegotiation: { mode } }) - onTestFinished(() => client.close()) + it.each(['legacy', 'auto'] as const)( + 'provides per-request context from the createMcpHandler factory (%s negotiation)', + async (mode) => { + const registrar = new McpRegistrar({ procedure }) - await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { - fetch: (url, init) => handler.fetch(new Request(url, init), { - authInfo: { token: user, clientId: 'test-client', scopes: [], extra: { user } }, - }), - })) - - return client.callTool({ name: 'procedure' }) - } + const handler = createMcpHandler(async ({ authInfo }) => { + const server = createTestServer() + await registrar.register(server, { context: { auth: authInfo?.extra?.user as string } }) + return server + }) + onTestFinished(() => handler.close()) + + const callAs = async (user: string) => { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { versionNegotiation: { mode } }, + ) + onTestFinished(() => client.close()) + + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: (url, init) => + handler.fetch(new Request(url, init), { + authInfo: { token: user, clientId: 'test-client', scopes: [], extra: { user } }, + }), + }), + ) + + return client.callTool({ name: 'procedure' }) + } - expect((await callAs('alice')).content).toEqual([{ type: 'text', text: 'alice' }]) - expect((await callAs('bob')).content).toEqual([{ type: 'text', text: 'bob' }]) - }) + expect((await callAs('alice')).content).toEqual([{ type: 'text', text: 'alice' }]) + expect((await callAs('bob')).content).toEqual([{ type: 'text', text: 'bob' }]) + }, + ) }) describe('standard handler', () => { @@ -841,48 +1109,96 @@ describe('mcpRegistrar', () => { const interceptor = vi.fn(({ next }) => next()) const handler = vi.fn(() => 'ok') - const client = await createTestClient('legacy', new McpRegistrar({ - planet: { ping: os.handler(handler) }, - }, { clientInterceptors: [interceptor] })) + const client = await createTestClient( + 'legacy', + new McpRegistrar( + { + planet: { ping: os.handler(handler) }, + }, + { clientInterceptors: [interceptor] }, + ), + ) await client.callTool({ name: 'planet_ping' }) expect(interceptor).toHaveBeenCalledTimes(1) - expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ - path: ['planet', 'ping'], - signal: expect.any(AbortSignal), - })) - expect(handler).toHaveBeenCalledWith(expect.objectContaining({ signal: expect.any(AbortSignal) }), {}) + expect(interceptor).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['planet', 'ping'], + signal: expect.any(AbortSignal), + }), + ) + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ signal: expect.any(AbortSignal) }), + {}, + ) }) it('runs plugins and interceptors around every tool call, with a request for the tool call', async () => { const routingInterceptor = vi.fn(({ next }) => next()) const interceptor = vi.fn(({ next }) => next()) - const init = vi.fn(options => ({ ...options, interceptors: [interceptor, ...(options.interceptors ?? [])] })) + const init = vi.fn((options) => ({ + ...options, + interceptors: [interceptor, ...(options.interceptors ?? [])], + })) - const client = await createTestClient('legacy', new McpRegistrar({ - planet: { find: os.$context<{ auth: string }>().input(z.object({ id: z.number() })).handler(({ input, context }) => `${context.auth} ${input.id}`) }, - }, { routingInterceptors: [routingInterceptor], plugins: [{ name: 'test', init }] }), { context: { auth: 'token' } }) + const client = await createTestClient( + 'legacy', + new McpRegistrar( + { + planet: { + find: os + .$context<{ auth: string }>() + .input(z.object({ id: z.number() })) + .handler(({ input, context }) => `${context.auth} ${input.id}`), + }, + }, + { routingInterceptors: [routingInterceptor], plugins: [{ name: 'test', init }] }, + ), + { context: { auth: 'token' } }, + ) - expect((await client.callTool({ name: 'planet_find', arguments: { id: 1 } })).content).toEqual([{ type: 'text', text: 'token 1' }]) + expect( + (await client.callTool({ name: 'planet_find', arguments: { id: 1 } })).content, + ).toEqual([{ type: 'text', text: 'token 1' }]) expect(init).toHaveBeenCalledTimes(1) const { request, context } = routingInterceptor.mock.calls[0]![0] - expect(request).toEqual(expect.objectContaining({ method: 'POST', url: '/planet/find', headers: {}, signal: expect.any(AbortSignal) })) + expect(request).toEqual( + expect.objectContaining({ + method: 'POST', + url: '/planet/find', + headers: {}, + signal: expect.any(AbortSignal), + }), + ) expect(await request.resolveBody()).toEqual({ id: 1 }) expect(context).toEqual(expect.objectContaining({ auth: 'token' })) - expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ path: ['planet', 'find'] })) + expect(interceptor).toHaveBeenCalledWith( + expect.objectContaining({ path: ['planet', 'find'] }), + ) }) it('carries the trace context of tool calls over as request headers', async () => { const routingInterceptor = vi.fn(({ next }) => next()) - const client = await createTestClient('legacy', new McpRegistrar({ ping: os.handler(() => 'pong') }, { routingInterceptors: [routingInterceptor] })) + const client = await createTestClient( + 'legacy', + new McpRegistrar( + { ping: os.handler(() => 'pong') }, + { routingInterceptors: [routingInterceptor] }, + ), + ) await client.callTool({ name: 'ping', - _meta: { traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', tracestate: 'vendor=1', baggage: 'user=alice', other: 'value' }, + _meta: { + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + tracestate: 'vendor=1', + baggage: 'user=alice', + other: 'value', + }, }) expect(routingInterceptor.mock.calls[0]![0].request.headers).toEqual({ @@ -893,24 +1209,59 @@ describe('mcpRegistrar', () => { }) it.each([ - ['an interceptor', { - interceptors: [() => { - throw new ORPCError('UNAUTHORIZED') - }], - }, {}, 'UNAUTHORIZED'], - ['a routing interceptor that does not match', { routingInterceptors: [async () => ({ matched: false })] }, {}, 'NOT_FOUND'], + [ + 'an interceptor', + { + interceptors: [ + () => { + throw new ORPCError('UNAUTHORIZED') + }, + ], + }, + {}, + 'UNAUTHORIZED', + ], + [ + 'a routing interceptor that does not match', + { routingInterceptors: [async () => ({ matched: false })] }, + {}, + 'NOT_FOUND', + ], // tool calls are routed by the procedure path - ['a routing interceptor that changes the URL', { routingInterceptors: [({ next, request }: any) => next({ request: { ...request, url: '/unknown' } })] }, {}, 'NOT_FOUND'], - ['the context', {}, { - context: () => { - throw new Error('context failed') + [ + 'a routing interceptor that changes the URL', + { + routingInterceptors: [ + ({ next, request }: any) => next({ request: { ...request, url: '/unknown' } }), + ], }, - }, 'INTERNAL_SERVER_ERROR'], - ])('reports errors from %s as tool execution errors', async (_, options: any, registerOptions: any, code) => { - const client = await createTestClient('legacy', new McpRegistrar({ procedure: os.handler(() => 'ok') }, options), registerOptions) - - expect(parseToolError(await client.callTool({ name: 'procedure' }))).toEqual(expect.objectContaining({ code })) - }) + {}, + 'NOT_FOUND', + ], + [ + 'the context', + {}, + { + context: () => { + throw new Error('context failed') + }, + }, + 'INTERNAL_SERVER_ERROR', + ], + ])( + 'reports errors from %s as tool execution errors', + async (_, options: any, registerOptions: any, code) => { + const client = await createTestClient( + 'legacy', + new McpRegistrar({ procedure: os.handler(() => 'ok') }, options), + registerOptions, + ) + + expect(parseToolError(await client.callTool({ name: 'procedure' }))).toEqual( + expect.objectContaining({ code }), + ) + }, + ) }) describe('contract-first', () => { @@ -935,7 +1286,13 @@ describe('mcpRegistrar', () => { const client = await createTestClient('legacy', new McpRegistrar(implemented)) const { tools } = await client.listTools() - expect(tools).toEqual([expect.objectContaining({ name: 'planet_find', title: 'Find a planet', annotations: { readOnlyHint: true } })]) + expect(tools).toEqual([ + expect.objectContaining({ + name: 'planet_find', + title: 'Find a planet', + annotations: { readOnlyHint: true }, + }), + ]) const result = await client.callTool({ name: 'planet_find', arguments: { id: 1 } }) expect(result.structuredContent).toEqual({ id: 1, name: 'Earth' }) @@ -945,11 +1302,13 @@ describe('mcpRegistrar', () => { const os = implement(contract) const implemented = os.router({ - planet: os.planet.lazy(() => Promise.resolve({ - default: os.planet.router({ - find: os.planet.find.handler(({ input }) => ({ id: input.id, name: 'Mars' })), + planet: os.planet.lazy(() => + Promise.resolve({ + default: os.planet.router({ + find: os.planet.find.handler(({ input }) => ({ id: input.id, name: 'Mars' })), + }), }), - })), + ), }) const client = await createTestClient('legacy', new McpRegistrar(implemented)) @@ -970,16 +1329,21 @@ describe('mcpRegistrar', () => { describe('register', () => { it('loads the router once and builds tool definitions on every registration', async () => { - const convert = vi.fn((_schema: unknown, _direction: string): [any, boolean] => [{ type: 'object', properties: { id: { type: 'number' } } }, false]) + const convert = vi.fn((_schema: unknown, _direction: string): [any, boolean] => [ + { type: 'object', properties: { id: { type: 'number' } } }, + false, + ]) const converter: JsonSchemaConverter = { condition: () => true, convert } - const loader = vi.fn(() => Promise.resolve({ - default: { - procedure: os - .input(z.object({ id: z.number() })) - .output(z.object({ id: z.number() })) - .handler(({ input }) => input), - }, - })) + const loader = vi.fn(() => + Promise.resolve({ + default: { + procedure: os + .input(z.object({ id: z.number() })) + .output(z.object({ id: z.number() })) + .handler(({ input }) => input), + }, + }), + ) const registrar = new McpRegistrar({ planet: os.lazy(loader) }, { converters: [converter] }) @@ -993,14 +1357,17 @@ describe('mcpRegistrar', () => { }) it('loads a lazy router again on the next registration after a failure', async () => { - const loader = vi.fn() + const loader = vi + .fn() .mockRejectedValueOnce(new Error('failed to load')) .mockResolvedValue({ default: { ping: os.handler(() => 'pong') } }) const registrar = new McpRegistrar({ planet: os.lazy(loader) }) await expect(registrar.register(createTestServer())).rejects.toThrow('failed to load') - expect(Object.keys((await registrar.register(createTestServer())).tools)).toEqual(['planet_ping']) + expect(Object.keys((await registrar.register(createTestServer())).tools)).toEqual([ + 'planet_ping', + ]) expect(loader).toHaveBeenCalledTimes(2) }) diff --git a/packages/mcp/src/registrar.ts b/packages/mcp/src/registrar.ts index 988a797f43..f6ac068c6a 100644 --- a/packages/mcp/src/registrar.ts +++ b/packages/mcp/src/registrar.ts @@ -1,23 +1,78 @@ -import type { CallToolResult, ContentBlock, EmbeddedResource, McpServer, RegisteredTool, ServerContext, StandardSchemaWithJSON, ToolAnnotations } from '@modelcontextprotocol/server' +import type { + CallToolResult, + ContentBlock, + EmbeddedResource, + McpServer, + RegisteredTool, + ServerContext, + StandardSchemaWithJSON, + ToolAnnotations, +} from '@modelcontextprotocol/server' +import { + BAGGAGE_META_KEY, + TRACEPARENT_META_KEY, + TRACESTATE_META_KEY, +} from '@modelcontextprotocol/server' import type { AnyORPCError } from '@orpc/client' +import { + COMMON_ERROR_STATUS_MAP, + ORPCError, + toORPCError, + wrapAsyncIteratorPreservingEventMeta, +} from '@orpc/client' import type { AnyProcedureContract } from '@orpc/contract' import type { JsonSchemaConverter } from '@orpc/json-schema' -import type { OpenAPIMeta } from '@orpc/openapi' -import type { AnyProcedure, AnyRouter, Context, InferRouterInitialContext, StandardHeaders, StandardLazyRequest, StandardResponse } from '@orpc/server' -import type { StandardHandlerCodec, StandardHandlerCodecResolvedProcedure, StandardHandlerOptions } from '@orpc/server/standard' -import type { MaybeOptionalOptions, Promisable, Public, Value } from '@orpc/shared' -import type { McpToolMeta } from './meta' -import type { ToolInputJsonSchema } from './utils' -import { BAGGAGE_META_KEY, TRACEPARENT_META_KEY, TRACESTATE_META_KEY } from '@modelcontextprotocol/server' -import { COMMON_ERROR_STATUS_MAP, ORPCError, toORPCError, wrapAsyncIteratorPreservingEventMeta } from '@orpc/client' import { DelegatingJsonSchemaConverter, StandardJsonSchemaConverter } from '@orpc/json-schema' +import type { OpenAPIMeta } from '@orpc/openapi' import { getOpenAPIMeta, OpenAPIJsonSerializer } from '@orpc/openapi' -import { DEFAULT_ERROR_STATUS, DEFAULT_SUCCESS_STATUS, ErrorEvent, resolveContractProcedure, walkProcedureContractsAsync } from '@orpc/server' +import type { + AnyProcedure, + AnyRouter, + Context, + InferRouterInitialContext, + StandardHeaders, + StandardLazyRequest, + StandardResponse, +} from '@orpc/server' +import { + DEFAULT_ERROR_STATUS, + DEFAULT_SUCCESS_STATUS, + ErrorEvent, + resolveContractProcedure, + walkProcedureContractsAsync, +} from '@orpc/server' +import type { + StandardHandlerCodec, + StandardHandlerCodecResolvedProcedure, + StandardHandlerOptions, +} from '@orpc/server/standard' import { StandardHandler } from '@orpc/server/standard' -import { encodeBase64, getOwn, isAsyncIteratorObject, isPlainObject, loadBytes, pathToHttpPath, resolveMaybeOptionalOptions, safeEncodeURIComponent, stringifyJSON, throwIfAborted, toArray, value } from '@orpc/shared' +import type { MaybeOptionalOptions, Promisable, Public, Value } from '@orpc/shared' +import { + encodeBase64, + getOwn, + isAsyncIteratorObject, + isPlainObject, + loadBytes, + pathToHttpPath, + resolveMaybeOptionalOptions, + safeEncodeURIComponent, + stringifyJSON, + throwIfAborted, + toArray, + value, +} from '@orpc/shared' import { flattenStandardHeader, parseStandardUrl } from '@standard-server/core' -import { getMcpToolMeta } from './meta' -import { resolveToolInput, resolveToolInputJsonSchema, resolveToolOutputJsonSchema, toPassthroughStandardSchema } from './utils' + +import type { McpToolMeta } from './meta.ts' +import { getMcpToolMeta } from './meta.ts' +import type { ToolInputJsonSchema } from './utils.ts' +import { + resolveToolInput, + resolveToolInputJsonSchema, + resolveToolOutputJsonSchema, + toPassthroughStandardSchema, +} from './utils.ts' /** * Options for {@link McpRegistrar}. Tool calls run through a standard handler, @@ -25,7 +80,9 @@ import { resolveToolInput, resolveToolInputJsonSchema, resolveToolOutputJsonSche * * @see {@link https://orpc.dev/docs/integrations/mcp#plugins-and-interceptors | MCP Integration - Plugins and Interceptors} */ -export interface McpRegistrarOptions extends StandardHandlerOptions { +export interface McpRegistrarOptions< + TInitialContext extends Context, +> extends StandardHandlerOptions { /** * Converters that turn input and output schemas into the JSON Schemas tools advertise. * Schemas implementing [Standard JSON Schema](https://standardschema.dev/json-schema), @@ -48,7 +105,9 @@ export interface McpRegistrarOptions extends St * * @default true */ - filter?: Value | undefined + filter?: + | Value + | undefined } /** @@ -58,8 +117,8 @@ export interface McpRegistrarOptions extends St * which exposes the HTTP request and verified auth info when served over HTTP. * It is optional only when an empty object satisfies the initial context. */ -export type McpRegistrarRegisterOptions - = object extends TInitialContext +export type McpRegistrarRegisterOptions = + object extends TInitialContext ? { context?: Value, [ctx: ServerContext]> | undefined } : { context: Value, [ctx: ServerContext]> } @@ -132,9 +191,17 @@ export class McpRegistrar { constructor( router: T, - { converters, serializer, filter, ...options }: McpRegistrarOptions> = {}, + { + converters, + serializer, + filter, + ...options + }: McpRegistrarOptions> = {}, ) { - this.converter = new DelegatingJsonSchemaConverter([...toArray(converters), new StandardJsonSchemaConverter()]) + this.converter = new DelegatingJsonSchemaConverter([ + ...toArray(converters), + new StandardJsonSchemaConverter(), + ]) this.matcher = new McpMatcher(router, this.converter, filter) this.codec = new McpHandlerCodec(this.matcher, serializer ?? new OpenAPIJsonSerializer()) this.handler = new StandardHandler(this.codec, options) @@ -162,8 +229,8 @@ export class McpRegistrar { if (existing !== undefined) { throw new TypeError( - `[MCP] Procedures at "${existing.path.join('.')}" and "${route.path.join('.')}" share the tool name "${tool.name}". ` - + `Set a unique one with mcp.tool({ name }), or exclude one with the filter option.`, + `[MCP] Procedures at "${existing.path.join('.')}" and "${route.path.join('.')}" share the tool name "${tool.name}". ` + + `Set a unique one with mcp.tool({ name }), or exclude one with the filter option.`, ) } @@ -179,16 +246,25 @@ export class McpRegistrar { return registered } - private createToolDefinition(url: `/${string}`, { path, contract, inputJsonSchema }: McpRoute, context: McpRegistrarRegisterOptions['context']): McpToolDefinition { + private createToolDefinition( + url: `/${string}`, + { path, contract, inputJsonSchema }: McpRoute, + context: McpRegistrarRegisterOptions['context'], + ): McpToolDefinition { const mcpMeta = getMcpToolMeta(contract) const openapiMeta = getOpenAPIMeta(contract) const name = mcpMeta?.name ?? openapiMeta?.operationId ?? path.join('_') if (name === '') { - throw new TypeError(`[MCP] Procedure at "${path.join('.') || '(root)'}" has no name. Set one with mcp.tool({ name }).`) + throw new TypeError( + `[MCP] Procedure at "${path.join('.') || '(root)'}" has no name. Set one with mcp.tool({ name }).`, + ) } - const outputJsonSchema = resolveToolOutputJsonSchema(this.converter, toArray(contract['~orpc'].outputSchemas)) + const outputJsonSchema = resolveToolOutputJsonSchema( + this.converter, + toArray(contract['~orpc'].outputSchemas), + ) const inferredAnnotations = openapiMeta?.method && METHOD_ANNOTATIONS[openapiMeta.method] return { @@ -199,9 +275,10 @@ export class McpRegistrar { description: mcpMeta?.description ?? openapiMeta?.description ?? openapiMeta?.summary, inputSchema: toPassthroughStandardSchema(inputJsonSchema.jsonSchema), outputSchema: outputJsonSchema && toPassthroughStandardSchema(outputJsonSchema), - annotations: inferredAnnotations || mcpMeta?.annotations - ? { ...inferredAnnotations, ...mcpMeta?.annotations } - : undefined, + annotations: + inferredAnnotations || mcpMeta?.annotations + ? { ...inferredAnnotations, ...mcpMeta?.annotations } + : undefined, icons: mcpMeta?.icons, _meta: mcpMeta?._meta, }, @@ -209,8 +286,14 @@ export class McpRegistrar { try { const { matched, response } = await this.handler.handle( // tool calls have no HTTP request of their own, so only the trace context is carried over - { method: 'POST', url, headers: toTraceHeaders(ctx.mcpReq._meta), signal: ctx.mcpReq.signal, resolveBody: () => Promise.resolve(args) }, - { context: async () => await value(context, ctx) ?? {} }, + { + method: 'POST', + url, + headers: toTraceHeaders(ctx.mcpReq._meta), + signal: ctx.mcpReq.signal, + resolveBody: () => Promise.resolve(args), + }, + { context: async () => (await value(context, ctx)) ?? {} }, ) if (!matched) { @@ -225,11 +308,15 @@ export class McpRegistrar { return await toCallToolResult(await consumeIteratorBody(response.body, ctx)) } - return await toCallToolResult(response.body, flattenStandardHeader(response.headers['content-type'])) - } - catch (e) { + return await toCallToolResult( + response.body, + flattenStandardHeader(response.headers['content-type']), + ) + } catch (e) { // a tool execution error the model can read, rather than a protocol error - return await toErrorCallToolResult(e instanceof ErrorEvent ? e.data : this.codec.encodeError(toORPCError(e)).body) + return await toErrorCallToolResult( + e instanceof ErrorEvent ? e.data : this.codec.encodeError(toORPCError(e)).body, + ) } }, } @@ -250,11 +337,11 @@ class McpMatcher { ) {} resolveRoutes(): Promise> { - return this.routes ??= this.loadRoutes().catch((error) => { + return (this.routes ??= this.loadRoutes().catch((error) => { // retry on the next call, such as when a lazy router failed to load this.routes = undefined throw error - }) + })) } async match(pathname: `/${string}`): Promise { @@ -273,7 +360,10 @@ class McpMatcher { path, contract, procedure: await resolveContractProcedure(this.router, contract, path), - inputJsonSchema: resolveToolInputJsonSchema(this.converter, toArray(contract['~orpc'].inputSchemas)), + inputJsonSchema: resolveToolInputJsonSchema( + this.converter, + toArray(contract['~orpc'].inputSchemas), + ), }) }) @@ -290,7 +380,9 @@ class McpHandlerCodec implements StandardHandlerCodec { private readonly serializer: Public, ) {} - async resolveProcedure(request: StandardLazyRequest): Promise { + async resolveProcedure( + request: StandardLazyRequest, + ): Promise { const [pathname] = parseStandardUrl(request.url) const route = await this.matcher.match(pathname) @@ -311,8 +403,8 @@ class McpHandlerCodec implements StandardHandlerCodec { status: DEFAULT_SUCCESS_STATUS, headers: {}, body: wrapAsyncIteratorPreservingEventMeta(output, { - mapResult: result => ({ done: result.done, value: this.serialize(result.value) }), - mapError: e => new ErrorEvent(this.serialize(toORPCError(e).toJSON()), { cause: e }), + mapResult: (result) => ({ done: result.done, value: this.serialize(result.value) }), + mapError: (e) => new ErrorEvent(this.serialize(toORPCError(e).toJSON()), { cause: e }), }), } } @@ -339,7 +431,10 @@ class McpHandlerCodec implements StandardHandlerCodec { * Tools return a single result, so the last event becomes the result, * and every event is reported as progress when the client asks for it. */ -async function consumeIteratorBody(iterator: AsyncIterable, ctx: ServerContext): Promise { +async function consumeIteratorBody( + iterator: AsyncIterable, + ctx: ServerContext, +): Promise { const progressToken = ctx.mcpReq._meta?.progressToken let last: unknown @@ -389,9 +484,14 @@ function toText(json: unknown): string | undefined { * A stream has no type of its own, so it takes the content type of the response, * such as one a procedure sets with the Response Headers Plugin. */ -async function toCallToolResult(serialized: unknown, contentType?: string): Promise { +async function toCallToolResult( + serialized: unknown, + contentType?: string, +): Promise { if (serialized instanceof ReadableStream) { - serialized = await new Response(serialized, { headers: contentType === undefined ? {} : { 'content-type': contentType } }).blob() + serialized = await new Response(serialized, { + headers: contentType === undefined ? {} : { 'content-type': contentType }, + }).blob() } if (serialized instanceof Blob) { @@ -403,13 +503,19 @@ async function toCallToolResult(serialized: unknown, contentType?: string): Prom } return { - content: [{ type: 'text', text: toText(serialized)! }, ...await toNestedBlobContents(serialized)], + content: [ + { type: 'text', text: toText(serialized)! }, + ...(await toNestedBlobContents(serialized)), + ], structuredContent: serialized, } } async function toErrorCallToolResult(json: unknown): Promise { - return { content: [{ type: 'text', text: stringifyJSON(json)! }, ...await toNestedBlobContents(json)], isError: true } + return { + content: [{ type: 'text', text: stringifyJSON(json)! }, ...(await toNestedBlobContents(json))], + isError: true, + } } /** @@ -422,11 +528,14 @@ function toNestedBlobContents(json: unknown): Promise { return Promise.all(blobs.map(([uri, blob]) => toEmbeddedResource(uri, blob))) } -function collectNestedBlobs(json: unknown, segments: string[], blobs: [uri: string, blob: Blob][]): void { +function collectNestedBlobs( + json: unknown, + segments: string[], + blobs: [uri: string, blob: Blob][], +): void { if (json instanceof Blob) { blobs.push([toResourceUri(segments, json), json]) - } - else if (Array.isArray(json) || isPlainObject(json)) { + } else if (Array.isArray(json) || isPlainObject(json)) { for (const key of Object.keys(json)) { segments.push(key) collectNestedBlobs((json as Record)[key], segments, blobs) @@ -450,7 +559,11 @@ async function toBinaryContent(blob: Blob): Promise { async function toEmbeddedResource(uri: string, blob: Blob): Promise { return { type: 'resource', - resource: { uri, mimeType: blob.type || 'application/octet-stream', blob: encodeBase64(await loadBytes(blob)) }, + resource: { + uri, + mimeType: blob.type || 'application/octet-stream', + blob: encodeBase64(await loadBytes(blob)), + }, } } diff --git a/packages/mcp/src/utils.test.ts b/packages/mcp/src/utils.test.ts index fa6d870c6f..d709aa556a 100644 --- a/packages/mcp/src/utils.test.ts +++ b/packages/mcp/src/utils.test.ts @@ -1,7 +1,13 @@ -import type { JsonSchemaConverter } from '@orpc/json-schema' import { asyncIteratorObject, type } from '@orpc/contract' +import type { JsonSchemaConverter } from '@orpc/json-schema' import { z } from 'zod' -import { resolveToolInput, resolveToolInputJsonSchema, resolveToolOutputJsonSchema, toPassthroughStandardSchema } from './utils' + +import { + resolveToolInput, + resolveToolInputJsonSchema, + resolveToolOutputJsonSchema, + toPassthroughStandardSchema, +} from './utils.ts' function createConverter(...results: [any, boolean][]): JsonSchemaConverter { let index = 0 @@ -22,7 +28,10 @@ describe('resolveToolInputJsonSchema', () => { }) it('keeps object schemas and strips $schema', () => { - const converter = createConverter([{ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object' }, false]) + const converter = createConverter([ + { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object' }, + false, + ]) expect(resolveToolInputJsonSchema(converter, [type()])).toEqual({ jsonSchema: { type: 'object' }, @@ -38,33 +47,51 @@ describe('resolveToolInputJsonSchema', () => { [{ $ref: '#/$defs/object', $defs: { object: { type: 'object' } } }, false], [{ $ref: '#/$defs/string', $defs: { string: { type: 'string' } } }, true], ])('nests %j under `input`: %s', (jsonSchema, wrapped) => { - expect(resolveToolInputJsonSchema(createConverter([jsonSchema, false]), [type()]).wrapped).toBe(wrapped) + expect(resolveToolInputJsonSchema(createConverter([jsonSchema, false]), [type()]).wrapped).toBe( + wrapped, + ) }) it('leaves unconstrained schemas for the MCP SDK to type as objects', () => { - expect(resolveToolInputJsonSchema(createConverter([true, true]), [type()]).jsonSchema).toEqual({}) - expect(resolveToolInputJsonSchema(createConverter([{ description: 'any' }, true]), [type()]).jsonSchema).toEqual({ description: 'any' }) + expect(resolveToolInputJsonSchema(createConverter([true, true]), [type()]).jsonSchema).toEqual( + {}, + ) + expect( + resolveToolInputJsonSchema(createConverter([{ description: 'any' }, true]), [type()]) + .jsonSchema, + ).toEqual({ description: 'any' }) }) it('nests other schemas under `input`, required unless optional', () => { - expect(resolveToolInputJsonSchema(createConverter([{ type: 'string' }, false]), [type()])).toEqual({ - jsonSchema: { type: 'object', properties: { input: { type: 'string' } }, required: ['input'] }, + expect( + resolveToolInputJsonSchema(createConverter([{ type: 'string' }, false]), [type()]), + ).toEqual({ + jsonSchema: { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'], + }, wrapped: true, emptyAsUndefined: false, }) - expect(resolveToolInputJsonSchema(createConverter([{ type: 'string' }, true]), [type()]).jsonSchema).toEqual({ + expect( + resolveToolInputJsonSchema(createConverter([{ type: 'string' }, true]), [type()]).jsonSchema, + ).toEqual({ type: 'object', properties: { input: { type: 'string' } }, }) }) it('moves defs to the root and rewrites root refs when nesting', () => { - const converter = createConverter([{ - type: 'array', - items: { $ref: '#' }, - $defs: { other: { type: 'string' } }, - }, false]) + const converter = createConverter([ + { + type: 'array', + items: { $ref: '#' }, + $defs: { other: { type: 'string' } }, + }, + false, + ]) expect(resolveToolInputJsonSchema(converter, [type()]).jsonSchema).toEqual({ type: 'object', @@ -78,28 +105,48 @@ describe('resolveToolInputJsonSchema', () => { }) it('combines multiple schemas with allOf', () => { - const converter = createConverter([{ type: 'object', required: ['a'] }, false], [{ type: 'object', required: ['b'] }, true]) + const converter = createConverter( + [{ type: 'object', required: ['a'] }, false], + [{ type: 'object', required: ['b'] }, true], + ) expect(resolveToolInputJsonSchema(converter, [type(), type()])).toEqual({ - jsonSchema: { allOf: [{ type: 'object', required: ['a'] }, { type: 'object', required: ['b'] }] }, + jsonSchema: { + allOf: [ + { type: 'object', required: ['a'] }, + { type: 'object', required: ['b'] }, + ], + }, wrapped: false, emptyAsUndefined: false, }) }) it('ignores unconstrained schemas when combining', () => { - const converter = createConverter([{ type: 'object', required: ['a'] }, false], [{ description: 'no JSON Schema' }, true]) + const converter = createConverter( + [{ type: 'object', required: ['a'] }, false], + [{ description: 'no JSON Schema' }, true], + ) - expect(resolveToolInputJsonSchema(converter, [type(), type()]).jsonSchema).toEqual({ type: 'object', required: ['a'] }) - expect(resolveToolInputJsonSchema(createConverter([{}, true]), [type(), type()]).jsonSchema).toEqual({}) + expect(resolveToolInputJsonSchema(converter, [type(), type()]).jsonSchema).toEqual({ + type: 'object', + required: ['a'], + }) + expect( + resolveToolInputJsonSchema(createConverter([{}, true]), [type(), type()]).jsonSchema, + ).toEqual({}) }) it('treats combined schemas as optional only when every schema is optional', () => { const optional = createConverter([{ type: 'string' }, true], [{ minLength: 1 }, true]) - expect(resolveToolInputJsonSchema(optional, [type(), type()]).jsonSchema.required).toBeUndefined() + expect( + resolveToolInputJsonSchema(optional, [type(), type()]).jsonSchema.required, + ).toBeUndefined() const required = createConverter([{ type: 'string' }, true], [{ minLength: 1 }, false]) - expect(resolveToolInputJsonSchema(required, [type(), type()]).jsonSchema.required).toEqual(['input']) + expect(resolveToolInputJsonSchema(required, [type(), type()]).jsonSchema.required).toEqual([ + 'input', + ]) }) }) @@ -109,17 +156,43 @@ describe('resolveToolOutputJsonSchema', () => { }) it('returns the converted schema', () => { - expect(resolveToolOutputJsonSchema(createConverter([{ type: 'array' }, false]), [type()])).toEqual({ type: 'array' }) + expect( + resolveToolOutputJsonSchema(createConverter([{ type: 'array' }, false]), [type()]), + ).toEqual({ type: 'array' }) }) it.each([ ['optional', [{ type: 'object' }, true]], ['unconstrained', [{}, false]], ['binary', [{ type: 'string', contentMediaType: 'image/png' }, false]], - ['possibly binary', [{ anyOf: [{ type: 'object' }, { type: 'string', contentMediaType: 'image/png' }] }, false]], - ['intersected binary', [{ allOf: [{ type: 'string' }, { type: 'string', contentMediaType: 'image/png' }] }, false]], - ['referenced binary', [{ $ref: '#/$defs/Image', $defs: { Image: { type: 'string', contentMediaType: 'image/png' } } }, false]], - ['nested binary', [{ type: 'object', properties: { image: { type: 'string', contentMediaType: 'image/png' } } }, false]], + [ + 'possibly binary', + [{ anyOf: [{ type: 'object' }, { type: 'string', contentMediaType: 'image/png' }] }, false], + ], + [ + 'intersected binary', + [{ allOf: [{ type: 'string' }, { type: 'string', contentMediaType: 'image/png' }] }, false], + ], + [ + 'referenced binary', + [ + { + $ref: '#/$defs/Image', + $defs: { Image: { type: 'string', contentMediaType: 'image/png' } }, + }, + false, + ], + ], + [ + 'nested binary', + [ + { + type: 'object', + properties: { image: { type: 'string', contentMediaType: 'image/png' } }, + }, + false, + ], + ], ] as const)('returns undefined for %s outputs', (_, result) => { expect(resolveToolOutputJsonSchema(createConverter(result as any), [type()])).toBeUndefined() }) @@ -142,14 +215,20 @@ describe('resolveToolOutputJsonSchema', () => { describe('resolveToolInput', () => { it('passes arguments through', () => { - const inputJsonSchema = resolveToolInputJsonSchema(createConverter([{ type: 'object' }, false]), [type()]) + const inputJsonSchema = resolveToolInputJsonSchema( + createConverter([{ type: 'object' }, false]), + [type()], + ) const args = { id: 1 } expect(resolveToolInput(args, inputJsonSchema)).toBe(args) }) it('unwraps nested inputs', () => { - const inputJsonSchema = resolveToolInputJsonSchema(createConverter([{ type: 'string' }, true]), [type()]) + const inputJsonSchema = resolveToolInputJsonSchema( + createConverter([{ type: 'string' }, true]), + [type()], + ) expect(resolveToolInput({ input: 'Earth' }, inputJsonSchema)).toBe('Earth') expect(resolveToolInput({}, inputJsonSchema)).toBeUndefined() @@ -161,25 +240,58 @@ describe('resolveToolInput empty arguments', () => { const objectConverter = createConverter([{ type: 'object' }, true]) it('passes no input for empty arguments when every input schema accepts undefined and one rejects an object', () => { - expect(resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [z.void()]))).toBeUndefined() - expect(resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [z.void().or(z.object({ id: z.number() }))]))).toBeUndefined() - expect(resolveToolInput({ id: 1 }, resolveToolInputJsonSchema(objectConverter, [z.void()]))).toEqual({ id: 1 }) + expect( + resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [z.void()])), + ).toBeUndefined() + expect( + resolveToolInput( + {}, + resolveToolInputJsonSchema(objectConverter, [z.void().or(z.object({ id: z.number() }))]), + ), + ).toBeUndefined() + expect( + resolveToolInput({ id: 1 }, resolveToolInputJsonSchema(objectConverter, [z.void()])), + ).toEqual({ id: 1 }) }) it('keeps empty arguments for inputs accepting an object or validating asynchronously', () => { expect(resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [type()]))).toEqual({}) expect(resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [z.any()]))).toEqual({}) - expect(resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [z.object({}).optional()]))).toEqual({}) - expect(resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [z.void().refine(async () => true)]))).toEqual({}) + expect( + resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [z.object({}).optional()])), + ).toEqual({}) + expect( + resolveToolInput( + {}, + resolveToolInputJsonSchema(objectConverter, [z.void().refine(async () => true)]), + ), + ).toEqual({}) // accepts undefined, but cannot tell synchronously whether it rejects an object - const probe = (onObject: () => any) => ({ - '~standard': { vendor: 'test', version: 1, validate: (value: unknown) => value === undefined ? { value } : onObject() }, - } as const) - expect(resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [probe(async () => ({ issues: [] }))]))).toEqual({}) - expect(resolveToolInput({}, resolveToolInputJsonSchema(objectConverter, [probe(() => { - throw new Error('boom') - })]))).toEqual({}) + const probe = (onObject: () => any) => + ({ + '~standard': { + vendor: 'test', + version: 1, + validate: (value: unknown) => (value === undefined ? { value } : onObject()), + }, + }) as const + expect( + resolveToolInput( + {}, + resolveToolInputJsonSchema(objectConverter, [probe(async () => ({ issues: [] }))]), + ), + ).toEqual({}) + expect( + resolveToolInput( + {}, + resolveToolInputJsonSchema(objectConverter, [ + probe(() => { + throw new Error('boom') + }), + ]), + ), + ).toEqual({}) }) }) @@ -188,6 +300,8 @@ it('toPassthroughStandardSchema', async () => { const value = { anything: true } expect(await schema['~standard'].validate(value)).toEqual({ value }) - expect(schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' })).toEqual({ type: 'object' }) + expect(schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' })).toEqual({ + type: 'object', + }) expect(schema['~standard'].jsonSchema.output({ target: 'draft-07' })).toEqual({ type: 'object' }) }) diff --git a/packages/mcp/src/utils.ts b/packages/mcp/src/utils.ts index 1dc34635ba..e36987fd89 100644 --- a/packages/mcp/src/utils.ts +++ b/packages/mcp/src/utils.ts @@ -1,8 +1,16 @@ import type { StandardSchemaWithJSON } from '@modelcontextprotocol/server' import type { AnySchema } from '@orpc/contract' -import type { JsonSchema, JsonSchemaConverter } from '@orpc/json-schema' import { getAsyncIteratorObjectSchemasDetails } from '@orpc/contract' -import { combineJsonSchemasWithComposition, containsJsonFileSchema, ensureJsonSchemaObject, hoistRecursiveRefToDef, isJsonObjectLikeSchema, isStandardSchemaOptional, isUnconstrainedSchema } from '@orpc/json-schema' +import type { JsonSchema, JsonSchemaConverter } from '@orpc/json-schema' +import { + combineJsonSchemasWithComposition, + containsJsonFileSchema, + ensureJsonSchemaObject, + hoistRecursiveRefToDef, + isJsonObjectLikeSchema, + isStandardSchemaOptional, + isUnconstrainedSchema, +} from '@orpc/json-schema' import { isTypescriptObject, ORPC_NAME } from '@orpc/shared' type JsonSchemaObject = Exclude @@ -42,8 +50,7 @@ function rejectsEmptyObject(schema: AnySchema): boolean { } return result.issues !== undefined - } - catch { + } catch { return false } } @@ -52,7 +59,10 @@ function rejectsEmptyObject(schema: AnySchema): boolean { * Probes the schemas themselves, since a JSON Schema cannot tell `z.void()` from a schema the converters do not support. */ function isEmptyAsUndefined(inputSchemas: AnySchema[]): boolean { - return inputSchemas.every(schema => isStandardSchemaOptional(schema, 'input')) && inputSchemas.some(rejectsEmptyObject) + return ( + inputSchemas.every((schema) => isStandardSchemaOptional(schema, 'input')) && + inputSchemas.some(rejectsEmptyObject) + ) } function convertSchema( @@ -79,7 +89,9 @@ function combineConvertedSchemas(converted: [JsonSchema, boolean][]): [JsonSchem } // an unconstrained schema adds nothing to `allOf`, such as one without JSON Schema support - const constrained = converted.map(([jsonSchema]) => jsonSchema).filter(jsonSchema => !isUnconstrainedSchema(jsonSchema)) + const constrained = converted + .map(([jsonSchema]) => jsonSchema) + .filter((jsonSchema) => !isUnconstrainedSchema(jsonSchema)) return [ combineJsonSchemasWithComposition('allOf', constrained), @@ -98,10 +110,14 @@ export function resolveToolInputJsonSchema( inputSchemas: AnySchema[], ): ToolInputJsonSchema { if (inputSchemas.length === 0) { - return { jsonSchema: { type: 'object', properties: {} }, wrapped: false, emptyAsUndefined: false } + return { + jsonSchema: { type: 'object', properties: {} }, + wrapped: false, + emptyAsUndefined: false, + } } - const converted = inputSchemas.map(schema => convertSchema(converter, schema, 'input')) + const converted = inputSchemas.map((schema) => convertSchema(converter, schema, 'input')) const [combined, optional] = combineConvertedSchemas(converted) const jsonSchema = ensureJsonSchemaObject(combined) @@ -129,13 +145,15 @@ export function resolveToolInputJsonSchema( * Empty arguments become `undefined` for inputs that accept no object. */ export function resolveToolInput(args: unknown, inputJsonSchema: ToolInputJsonSchema): unknown { - if (inputJsonSchema.emptyAsUndefined && isTypescriptObject(args) && Object.keys(args).length === 0) { + if ( + inputJsonSchema.emptyAsUndefined && + isTypescriptObject(args) && + Object.keys(args).length === 0 + ) { return undefined } - return inputJsonSchema.wrapped && isTypescriptObject(args) - ? args[WRAPPED_INPUT_KEY] - : args + return inputJsonSchema.wrapped && isTypescriptObject(args) ? args[WRAPPED_INPUT_KEY] : args } /** @@ -154,7 +172,9 @@ export function resolveToolOutputJsonSchema( } const yieldSchemas = getAsyncIteratorObjectSchemasDetails(outputSchemas)?.yieldSchemas - const converted = (yieldSchemas ?? outputSchemas).map(schema => convertSchema(converter, schema, 'output')) + const converted = (yieldSchemas ?? outputSchemas).map((schema) => + convertSchema(converter, schema, 'output'), + ) const [combined, optional] = combineConvertedSchemas(converted) // an output schema requires structured content matching it in every result, which undefined, unknown, or binary outputs diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index 7a3f25403a..9979a0715c 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -1,19 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../contract" }, - { "path": "../json-schema" }, - { "path": "../openapi" }, - { "path": "../server" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/msw/README.md b/packages/msw/README.md index ca193b6c2e..957bae981b 100644 --- a/packages/msw/README.md +++ b/packages/msw/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/msw/build.config.ts b/packages/msw/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/msw/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/msw/package.json b/packages/msw/package.json index cf4365e916..17af1ead78 100644 --- a/packages/msw/package.json +++ b/packages/msw/package.json @@ -1,48 +1,44 @@ { "name": "@orpc/msw", - "type": "module", "version": "2.0.0-beta.48", "description": "MSW integration for oRPC: mock procedures at the network level with typed MSW request handlers", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "mock-service-worker", + "msw", + "orpc" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/msw" }, - "keywords": [ - "orpc", - "msw", - "mock-service-worker" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "msw": ">=2.12.3" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", @@ -55,5 +51,8 @@ "msw": "^3.0.3", "msw-v2": "npm:msw@^2.15.0", "zod": "^4.6.7" + }, + "peerDependencies": { + "msw": ">=2.12.3" } } diff --git a/packages/msw/src/http-procedure-utils.openapi.test.ts b/packages/msw/src/http-procedure-utils.openapi.test.ts index 4b6fce7427..152cca47e5 100644 --- a/packages/msw/src/http-procedure-utils.openapi.test.ts +++ b/packages/msw/src/http-procedure-utils.openapi.test.ts @@ -1,13 +1,14 @@ -import type { RouterContractClient } from '@orpc/contract' -import type { JsonifiedClient } from '@orpc/openapi' import { createORPCClient, isDefinedError, ORPCError } from '@orpc/client' +import type { RouterContractClient } from '@orpc/contract' import { oc } from '@orpc/contract' +import type { JsonifiedClient } from '@orpc/openapi' import { openapi } from '@orpc/openapi' import { OpenAPIHandler, OpenAPILink } from '@orpc/openapi/fetch' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import z from 'zod' -import { createHTTPUtils } from './index' +import * as z from 'zod' + +import { createHTTPUtils } from './index.ts' const contract = { planet: { @@ -38,13 +39,15 @@ afterAll(() => server.close()) const mock = createHTTPUtils(contract, { origin: 'http://localhost:3000', prefix: '/api', - handler: router => new OpenAPIHandler(router), + handler: (router) => new OpenAPIHandler(router), }) -const client: JsonifiedClient> = createORPCClient(new OpenAPILink(contract, { - origin: 'http://localhost:3000', - url: '/api', -})) +const client: JsonifiedClient> = createORPCClient( + new OpenAPILink(contract, { + origin: 'http://localhost:3000', + url: '/api', + }), +) it('mocks custom routes with path parameters', async () => { const fn = vi.fn(({ input }: { input: { id: number } }) => ({ id: input.id, name: 'Earth' })) @@ -72,7 +75,9 @@ it('mocks default routes', async () => { it('mocks routes with greedy path parameters', async () => { server.use(mock.file.handler(({ input }) => input.path)) - await expect(client.file({ path: 'images/planets/earth.png' })).resolves.toBe('images/planets/earth.png') + await expect(client.file({ path: 'images/planets/earth.png' })).resolves.toBe( + 'images/planets/earth.png', + ) }) it('responds with defined errors', async () => { @@ -81,8 +86,7 @@ it('responds with defined errors', async () => { try { await client.planet.find({ id: 42 }) expect.unreachable() - } - catch (error) { + } catch (error) { expect(error).toBeInstanceOf(ORPCError) expect(isDefinedError(error)).toBe(true) expect((error as any).code).toBe('NOT_FOUND') @@ -95,28 +99,38 @@ it('supports wildcard base urls', async () => { const wildcardMock = createHTTPUtils(contract, { origin: '*', prefix: '/api', - handler: router => new OpenAPIHandler(router), + handler: (router) => new OpenAPIHandler(router), }) server.use(wildcardMock.planet.create.handler(({ input }) => ({ id: 1, name: input.name }))) - const wildcardClient: JsonifiedClient> = createORPCClient(new OpenAPILink(contract, { - origin: 'http://example.com', - url: '/api', - })) + const wildcardClient: JsonifiedClient> = createORPCClient( + new OpenAPILink(contract, { + origin: 'http://example.com', + url: '/api', + }), + ) - await expect(wildcardClient.planet.create({ name: 'Pluto' })).resolves.toEqual({ id: 1, name: 'Pluto' }) + await expect(wildcardClient.planet.create({ name: 'Pluto' })).resolves.toEqual({ + id: 1, + name: 'Pluto', + }) }) it('leaves requests with unmatched methods to other msw handlers', async () => { server.use( mock.planet.find.handler(({ input }) => ({ id: input.id, name: 'Earth' })), - http.all('http://localhost:3000/*', async ({ request }) => HttpResponse.text(`fallback: ${await request.text()}`)), + http.all('http://localhost:3000/*', async ({ request }) => + HttpResponse.text(`fallback: ${await request.text()}`), + ), ) // the find route only accepts GET, so a POST should fall through // with its body untouched, since matching never reads the body - const response = await fetch('http://localhost:3000/api/planets/42', { method: 'POST', body: 'raw-body' }) + const response = await fetch('http://localhost:3000/api/planets/42', { + method: 'POST', + body: 'raw-body', + }) await expect(response.text()).resolves.toBe('fallback: raw-body') }) diff --git a/packages/msw/src/http-procedure-utils.test-d.ts b/packages/msw/src/http-procedure-utils.test-d.ts index 7b4e19c7b1..cac313a9b8 100644 --- a/packages/msw/src/http-procedure-utils.test-d.ts +++ b/packages/msw/src/http-procedure-utils.test-d.ts @@ -1,15 +1,16 @@ -import type { HttpHandler } from 'msw' import { oc } from '@orpc/contract' import { OpenAPIHandler } from '@orpc/openapi/fetch' import { RPCHandler } from '@orpc/server/fetch' -import z from 'zod' -import { createHTTPUtils } from './http-router-utils' +import type { HttpHandler } from 'msw' +import * as z from 'zod' -const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) -const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) +import { createHTTPUtils } from './http-router-utils.ts' + +const inputSchema = z.object({ input: z.number().transform((n) => `${n}`) }) +const outputSchema = z.object({ output: z.number().transform((n) => `${n}`) }) const baseErrorMap = { - BASE: { data: z.object({ id: z.number().transform(n => `${n}`) }) }, + BASE: { data: z.object({ id: z.number().transform((n) => `${n}`) }) }, SIMPLE: {}, } @@ -20,7 +21,10 @@ const contract = { }, } -const utils = createHTTPUtils(contract, { prefix: '/rpc', handler: router => new RPCHandler(router) }) +const utils = createHTTPUtils(contract, { + prefix: '/rpc', + handler: (router) => new RPCHandler(router), +}) it('handler', () => { const handler = utils.ping.handler(({ input, errors, context, signal, lastEventId }) => { @@ -74,7 +78,7 @@ it('loading', () => { it('context option', () => { const contextUtils = createHTTPUtils(contract, { context: () => ({ userId: '1' }), - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) contextUtils.ping.handler(({ context }) => { @@ -93,12 +97,12 @@ it('context option', () => { // required when an empty object cannot satisfy it createHTTPUtils(contract, { context: (): { userId: string } => ({ userId: '1' }), - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) // @ts-expect-error --- context is required createHTTPUtils(contract, { - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) }) @@ -107,14 +111,22 @@ it('passthrough', () => { }) it('options', () => { - createHTTPUtils(contract, { handler: router => new RPCHandler(router) }) - createHTTPUtils(contract, { origin: '*', prefix: '/rpc', handler: router => new RPCHandler(router) }) - createHTTPUtils(contract, { origin: 'http://localhost:3000', prefix: '/api', handler: router => new OpenAPIHandler(router) }) + createHTTPUtils(contract, { handler: (router) => new RPCHandler(router) }) + createHTTPUtils(contract, { + origin: '*', + prefix: '/rpc', + handler: (router) => new RPCHandler(router), + }) + createHTTPUtils(contract, { + origin: 'http://localhost:3000', + prefix: '/api', + handler: (router) => new OpenAPIHandler(router), + }) // @ts-expect-error --- handler is required createHTTPUtils(contract, { origin: '*' }) // @ts-expect-error --- must return a fetch handler createHTTPUtils(contract, { handler: () => ({}) }) // @ts-expect-error --- prefix must start with / - createHTTPUtils(contract, { prefix: 'rpc', handler: router => new RPCHandler(router) }) + createHTTPUtils(contract, { prefix: 'rpc', handler: (router) => new RPCHandler(router) }) }) diff --git a/packages/msw/src/http-procedure-utils.test.ts b/packages/msw/src/http-procedure-utils.test.ts index af0b7e7ba0..8eef5050db 100644 --- a/packages/msw/src/http-procedure-utils.test.ts +++ b/packages/msw/src/http-procedure-utils.test.ts @@ -1,17 +1,19 @@ -import type { RouterContractClient } from '@orpc/contract' -import type { AnyRouter } from '@orpc/server' -import type { ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' -import type { AddressInfo } from 'node:net' import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' + import { createORPCClient, isDefinedError, ORPCError } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterContractClient } from '@orpc/contract' import { asyncIteratorObject, oc } from '@orpc/contract' +import type { AnyRouter } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' +import type { ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' import { ResponseHeadersHandlerPlugin } from '@orpc/server/plugins' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import z from 'zod' -import { createHTTPUtils } from './index' +import * as z from 'zod' + +import { createHTTPUtils } from './index.ts' const contract = { planet: { @@ -19,8 +21,7 @@ const contract = { .errors({ NOT_FOUND: { message: 'Planet not found', data: z.object({ id: z.number() }) } }) .input(z.object({ id: z.number() })) .output(z.object({ id: z.number(), name: z.string(), discoveredAt: z.date() })), - list: oc - .output(z.array(z.object({ id: z.number(), name: z.string() }))), + list: oc.output(z.array(z.object({ id: z.number(), name: z.string() }))), updates: oc .input(z.object({ id: z.number() })) .output(asyncIteratorObject(z.object({ message: z.string() }))), @@ -36,21 +37,25 @@ afterAll(() => server.close()) const orpc = createHTTPUtils(contract, { origin: 'http://localhost:3000', prefix: '/rpc', - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) -const client: RouterContractClient = createORPCClient(new RPCLink({ - origin: 'http://localhost:3000', - url: '/rpc', -})) +const client: RouterContractClient = createORPCClient( + new RPCLink({ + origin: 'http://localhost:3000', + url: '/rpc', + }), +) describe('handler', () => { it('resolves with rpc serialization intact', async () => { - server.use(orpc.planet.find.handler(({ input }) => ({ - id: input.id, - name: 'Earth', - discoveredAt: new Date('2020-01-01'), - }))) + server.use( + orpc.planet.find.handler(({ input }) => ({ + id: input.id, + name: 'Earth', + discoveredAt: new Date('2020-01-01'), + })), + ) const planet = await client.planet.find({ id: 42 }) @@ -69,8 +74,8 @@ describe('handler', () => { origin: 'http://localhost:3000', prefix: '/rpc', // clone so the raw body stays readable after input deserialization - context: info => ({ request: info.request.clone(), requestId: info.requestId }), - handler: router => new RPCHandler(router), + context: (info) => ({ request: info.request.clone(), requestId: info.requestId }), + handler: (router) => new RPCHandler(router), }) server.use(infoORPC.planet.find.handler(fn)) @@ -84,14 +89,16 @@ describe('handler', () => { }) it('validates input like the real rpc handler', async () => { - server.use(orpc.planet.find.handler(({ input }) => ({ - id: input.id, - name: 'Earth', - discoveredAt: new Date('2020-01-01'), - }))) + server.use( + orpc.planet.find.handler(({ input }) => ({ + id: input.id, + name: 'Earth', + discoveredAt: new Date('2020-01-01'), + })), + ) await expect(client.planet.find({ id: 'invalid' } as any)).rejects.toSatisfy( - error => error instanceof ORPCError && error.code === 'BAD_REQUEST', + (error) => error instanceof ORPCError && error.code === 'BAD_REQUEST', ) }) @@ -99,20 +106,21 @@ describe('handler', () => { server.use(orpc.planet.find.handler(() => ({ id: 1 }) as any)) await expect(client.planet.find({ id: 1 })).rejects.toSatisfy( - error => error instanceof ORPCError && error.code === 'INTERNAL_SERVER_ERROR', + (error) => error instanceof ORPCError && error.code === 'INTERNAL_SERVER_ERROR', ) }) it('supports throwing typed errors via the errors constructors', async () => { - server.use(orpc.planet.find.handler(({ input, errors }) => { - throw errors.NOT_FOUND({ data: { id: input.id } }) - })) + server.use( + orpc.planet.find.handler(({ input, errors }) => { + throw errors.NOT_FOUND({ data: { id: input.id } }) + }), + ) try { await client.planet.find({ id: 7 }) expect.unreachable() - } - catch (error) { + } catch (error) { expect(error).toBeInstanceOf(ORPCError) expect(isDefinedError(error)).toBe(true) expect((error as any).code).toBe('NOT_FOUND') @@ -122,10 +130,12 @@ describe('handler', () => { }) it('supports event iterator outputs', async () => { - server.use(orpc.planet.updates.handler(async function* () { - yield { message: 'hello' } - yield { message: 'world' } - })) + server.use( + orpc.planet.updates.handler(async function* () { + yield { message: 'hello' } + yield { message: 'world' } + }), + ) const messages: string[] = [] @@ -137,7 +147,10 @@ describe('handler', () => { }) it('matches any origin by default', async () => { - const defaultORPC = createHTTPUtils(contract, { prefix: '/rpc', handler: router => new RPCHandler(router) }) + const defaultORPC = createHTTPUtils(contract, { + prefix: '/rpc', + handler: (router) => new RPCHandler(router), + }) server.use(defaultORPC.planet.list.handler(() => [{ id: 1, name: 'Venus' }])) @@ -145,14 +158,20 @@ describe('handler', () => { }) it('supports wildcard base urls', async () => { - const wildcardORPC = createHTTPUtils(contract, { origin: '*', prefix: '/api/rpc', handler: router => new RPCHandler(router) }) + const wildcardORPC = createHTTPUtils(contract, { + origin: '*', + prefix: '/api/rpc', + handler: (router) => new RPCHandler(router), + }) server.use(wildcardORPC.planet.list.handler(() => [{ id: 1, name: 'Mars' }])) - const wildcardClient: RouterContractClient = createORPCClient(new RPCLink({ - origin: 'http://example.com', - url: '/api/rpc', - })) + const wildcardClient: RouterContractClient = createORPCClient( + new RPCLink({ + origin: 'http://example.com', + url: '/api/rpc', + }), + ) await expect(wildcardClient.planet.list()).resolves.toEqual([{ id: 1, name: 'Mars' }]) }) @@ -161,7 +180,7 @@ describe('handler', () => { const slashORPC = createHTTPUtils(contract, { origin: 'http://localhost:3000/', prefix: '/rpc', - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) server.use(slashORPC.planet.list.handler(() => [{ id: 1, name: 'Jupiter' }])) @@ -170,17 +189,22 @@ describe('handler', () => { }) it('serves mocks through the provided handler, e.g. with plugins', async () => { - const factory = vi.fn((router: AnyRouter) => new RPCHandler(router, { - routingInterceptors: [async ({ next }) => { - const result = await next() - - if (result.matched) { - result.response.headers['x-mocked'] = '1' - } - - return result - }], - })) + const factory = vi.fn( + (router: AnyRouter) => + new RPCHandler(router, { + routingInterceptors: [ + async ({ next }) => { + const result = await next() + + if (result.matched) { + result.response.headers['x-mocked'] = '1' + } + + return result + }, + ], + }), + ) const customORPC = createHTTPUtils(contract, { origin: 'http://localhost:3000', @@ -212,16 +236,19 @@ describe('handler', () => { origin: 'http://localhost:3000', prefix: '/rpc', context: (info): MockServerContext => ({ request: info.request }), - handler: router => new RPCHandler(router, { - plugins: [new ResponseHeadersHandlerPlugin()], - }), + handler: (router) => + new RPCHandler(router, { + plugins: [new ResponseHeadersHandlerPlugin()], + }), }) - server.use(contextORPC.planet.list.handler(({ context }) => { - expect(context.request).toBeInstanceOf(Request) - context.resHeaders?.set('x-mocked-context', '1') - return [] - })) + server.use( + contextORPC.planet.list.handler(({ context }) => { + expect(context.request).toBeInstanceOf(Request) + context.resHeaders?.set('x-mocked-context', '1') + return [] + }), + ) const response = await fetch('http://localhost:3000/rpc/planet/list', { method: 'POST', @@ -237,7 +264,7 @@ describe('handler', () => { const sameOriginORPC = createHTTPUtils(contract, { origin: '', prefix: '/rpc', - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) server.use( @@ -275,8 +302,7 @@ describe('error', () => { try { await client.planet.find({ id: 42 }) expect.unreachable() - } - catch (error) { + } catch (error) { expect(error).toBeInstanceOf(ORPCError) expect(isDefinedError(error)).toBe(true) expect((error as any).code).toBe('NOT_FOUND') @@ -296,17 +322,17 @@ describe('passthrough', () => { it('performs the request against the real server', async () => { const realServer = createServer((req, res) => { let body = '' - req.on('data', chunk => (body += chunk)) + req.on('data', (chunk) => (body += chunk)) req.on('end', () => res.end(`real:${body}`)) }) - await new Promise(resolve => realServer.listen(0, resolve)) + await new Promise((resolve) => realServer.listen(0, resolve)) const port = (realServer.address() as AddressInfo).port try { const realORPC = createHTTPUtils(contract, { origin: `http://localhost:${port}`, prefix: '/rpc', - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) server.use( @@ -319,7 +345,10 @@ describe('passthrough', () => { ) // the body must survive the matching attempt to reach the real server - const response = await fetch(`http://localhost:${port}/rpc/planet/list`, { method: 'POST', body: '{}' }) + const response = await fetch(`http://localhost:${port}/rpc/planet/list`, { + method: 'POST', + body: '{}', + }) await expect(response.text()).resolves.toBe('real:{}') @@ -334,8 +363,7 @@ describe('passthrough', () => { json: { id: 1, name: 'Earth', discoveredAt: '2020-01-01T00:00:00.000Z' }, meta: [['date', 'discoveredAt']], }) - } - finally { + } finally { realServer.close() } }) @@ -350,10 +378,10 @@ describe('loading', () => { prefix: '/rpc', context: async () => { controller.abort() - await new Promise(resolve => setTimeout(resolve, 20)) + await new Promise((resolve) => setTimeout(resolve, 20)) return {} }, - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) server.use(abortedORPC.planet.list.loading()) @@ -368,14 +396,13 @@ describe('loading', () => { const pending = client.planet.list(undefined, { signal: controller.signal }) pending.catch(() => {}) // silence the abort rejection below - await expect(Promise.race([ - pending, - new Promise(resolve => setTimeout(resolve, 100, 'still-loading')), - ])).resolves.toBe('still-loading') + await expect( + Promise.race([pending, new Promise((resolve) => setTimeout(resolve, 100, 'still-loading'))]), + ).resolves.toBe('still-loading') // aborting rejects the pending mock, releasing its resources controller.abort() await expect(pending).rejects.toThrow() - await new Promise(resolve => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)) }) }) diff --git a/packages/msw/src/http-procedure-utils.ts b/packages/msw/src/http-procedure-utils.ts index 923a9e364f..6816208191 100644 --- a/packages/msw/src/http-procedure-utils.ts +++ b/packages/msw/src/http-procedure-utils.ts @@ -1,69 +1,83 @@ import type { AnyORPCError } from '@orpc/client' -import type { AnyProcedureContract, AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, ORPCErrorConstructorMap } from '@orpc/contract' +import type { + AnyProcedureContract, + AnySchema, + ErrorMap, + InferSchemaInput, + InferSchemaOutput, + ORPCErrorConstructorMap, +} from '@orpc/contract' import type { Context, ProcedureConfig, ProcedureHandler, Router } from '@orpc/server' +import { implement } from '@orpc/server' import type { FetchHandler, FetchHandlerHandleResult } from '@orpc/server/fetch' import type { Promisable, Value } from '@orpc/shared' -import type { HttpHandler, HttpRequestResolverExtras, PathParams, ResponseResolverInfo } from 'msw' -import { implement } from '@orpc/server' import { value } from '@orpc/shared' +import type { HttpHandler, HttpRequestResolverExtras, PathParams, ResponseResolverInfo } from 'msw' import { http, passthrough } from 'msw' -export type HTTPProcedureUtilsOptions - = & ProcedureConfig - & { - /** - * The origin requests are matched against. Supports MSW wildcards. - * Set `''` to match only same-origin requests in the browser. - * - * @default '*' - */ - origin?: string | undefined +export type HTTPProcedureUtilsOptions = ProcedureConfig & { + /** + * The origin requests are matched against. Supports MSW wildcards. + * Set `''` to match only same-origin requests in the browser. + * + * @default '*' + */ + origin?: string | undefined - /** - * The path prefix procedures are served under, matching the `prefix` - * the corresponding handler is mounted at in production. - */ - prefix?: `/${string}` | undefined + /** + * The path prefix procedures are served under, matching the `prefix` + * the corresponding handler is mounted at in production. + */ + prefix?: `/${string}` | undefined - /** - * Creates the fetch handler that serves each mock, using the same - * configuration as your production handler, such as plugins. Requests are - * matched entirely by the created handler, so any protocol works. - * - * The router passed in contains only the procedure being mocked. Requests - * the created handler does not match fall through to other MSW handlers. - * - * @see {@link https://orpc.dev/docs/integrations/msw#advanced-configuration | MSW Integration - Advanced Configuration} - */ - handler: (router: Router) => NoInfer> - } - & (object extends TContext ? { - /** - * The context passed to the created handler for each request, resolved - * with the MSW resolver information. Mock handlers receive it as - * `context`, enabling context-driven behaviors such as the Response - * Headers Plugin. - * - * Optional when an empty object can satisfy the context type, - * required otherwise. - * - * @see {@link https://orpc.dev/docs/integrations/msw#advanced-configuration | MSW Integration - Advanced Configuration} - */ - context?: Value, [info: ResponseResolverInfo>]> | undefined - } : { - /** - * The context passed to the created handler for each request, resolved - * with the MSW resolver information. Mock handlers receive it as - * `context`, enabling context-driven behaviors such as the Response - * Headers Plugin. - * - * Optional when an empty object can satisfy the context type, - * required otherwise. - * - * @see {@link https://orpc.dev/docs/integrations/msw#advanced-configuration | MSW Integration - Advanced Configuration} - */ - context: Value, [info: ResponseResolverInfo>]> - }) + /** + * Creates the fetch handler that serves each mock, using the same + * configuration as your production handler, such as plugins. Requests are + * matched entirely by the created handler, so any protocol works. + * + * The router passed in contains only the procedure being mocked. Requests + * the created handler does not match fall through to other MSW handlers. + * + * @see {@link https://orpc.dev/docs/integrations/msw#advanced-configuration | MSW Integration - Advanced Configuration} + */ + handler: (router: Router) => NoInfer> +} & (object extends TContext + ? { + /** + * The context passed to the created handler for each request, resolved + * with the MSW resolver information. Mock handlers receive it as + * `context`, enabling context-driven behaviors such as the Response + * Headers Plugin. + * + * Optional when an empty object can satisfy the context type, + * required otherwise. + * + * @see {@link https://orpc.dev/docs/integrations/msw#advanced-configuration | MSW Integration - Advanced Configuration} + */ + context?: + | Value< + Promisable, + [info: ResponseResolverInfo>] + > + | undefined + } + : { + /** + * The context passed to the created handler for each request, resolved + * with the MSW resolver information. Mock handlers receive it as + * `context`, enabling context-driven behaviors such as the Response + * Headers Plugin. + * + * Optional when an empty object can satisfy the context type, + * required otherwise. + * + * @see {@link https://orpc.dev/docs/integrations/msw#advanced-configuration | MSW Integration - Advanced Configuration} + */ + context: Value< + Promisable, + [info: ResponseResolverInfo>] + > + }) /** * Creates typed MSW HTTP request handlers for a procedure-contract. Requests @@ -92,9 +106,10 @@ export class HTTPProcedureUtils< private readonly options: HTTPProcedureUtilsOptions, ) { const origin = (this.options.origin ?? '*').replace(/\/+$/, '') - this.prefix = this.options.prefix === undefined - ? undefined - : `/${this.options.prefix.replace(/\/+$/, '').slice(1)}` + this.prefix = + this.options.prefix === undefined + ? undefined + : `/${this.options.prefix.replace(/\/+$/, '').slice(1)}` const base = `${origin}${this.prefix === undefined || this.prefix === '/' ? '' : this.prefix}` this.mswPathPredicate = base === '*' ? '*' : `${base}/*` @@ -119,7 +134,7 @@ export class HTTPProcedureUtils< disableOutputValidation: this.options.disableOutputValidation, }).handler(handler as ProcedureHandler) - return this.toMSWHandler(procedure, ({ matched, response }) => matched ? response : undefined) + return this.toMSWHandler(procedure, ({ matched, response }) => (matched ? response : undefined)) } /** @@ -145,14 +160,17 @@ export class HTTPProcedureUtils< * @see {@link https://orpc.dev/docs/integrations/msw#mocking-loading-states | MSW Integration - Mocking Loading States} */ loading(): HttpHandler { - return this.handler(({ signal }) => new Promise((_, reject) => { - if (signal?.aborted) { - reject(signal.reason) - return - } - - signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) - })) + return this.handler( + ({ signal }) => + new Promise((_, reject) => { + if (signal?.aborted) { + reject(signal.reason) + return + } + + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }), + ) } /** @@ -171,7 +189,11 @@ export class HTTPProcedureUtils< disableOutputValidation: true, }).handler(() => undefined) - return this.toMSWHandler(procedure, ({ matched }) => matched ? passthrough() : undefined, true) + return this.toMSWHandler( + procedure, + ({ matched }) => (matched ? passthrough() : undefined), + true, + ) } /** @@ -195,7 +217,7 @@ export class HTTPProcedureUtils< const fetchHandler = this.options.handler(router) return http.all(this.mswPathPredicate, async (info) => { - const context = (await value(this.options.context, info) ?? {}) as TContext + const context = ((await value(this.options.context, info)) ?? {}) as TContext /** * Matching never reads the body, so unmatched requests fall through diff --git a/packages/msw/src/http-router-utils.test-d.ts b/packages/msw/src/http-router-utils.test-d.ts index a388f23d84..ce011314f0 100644 --- a/packages/msw/src/http-router-utils.test-d.ts +++ b/packages/msw/src/http-router-utils.test-d.ts @@ -1,13 +1,14 @@ -import type { HTTPProcedureUtils } from './http-procedure-utils' -import type { HTTPRouterUtils } from './http-router-utils' import { oc } from '@orpc/contract' import { os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' -import z from 'zod' -import { createHTTPUtils } from './http-router-utils' +import * as z from 'zod' -const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) -const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) +import type { HTTPProcedureUtils } from './http-procedure-utils.ts' +import type { HTTPRouterUtils } from './http-router-utils.ts' +import { createHTTPUtils } from './http-router-utils.ts' + +const inputSchema = z.object({ input: z.number().transform((n) => `${n}`) }) +const outputSchema = z.object({ output: z.number().transform((n) => `${n}`) }) const contract = { ping: oc.input(inputSchema).output(outputSchema), @@ -17,10 +18,12 @@ const contract = { } it('mirrors the router-contract shape', () => { - const utils = createHTTPUtils(contract, { handler: router => new RPCHandler(router) }) + const utils = createHTTPUtils(contract, { handler: (router) => new RPCHandler(router) }) expectTypeOf(utils).toEqualTypeOf>() - expectTypeOf(utils.ping).toExtend>() + expectTypeOf(utils.ping).toExtend< + HTTPProcedureUtils + >() expectTypeOf(utils.nested.pong).toExtend<{ loading: () => unknown }>() // @ts-expect-error --- not a procedure @@ -35,7 +38,7 @@ it('supports implemented routers', () => { .handler(({ input }) => ({ output: Number(input.input) })), } - const utils = createHTTPUtils(router, { handler: router => new RPCHandler(router) }) + const utils = createHTTPUtils(router, { handler: (router) => new RPCHandler(router) }) utils.ping.handler(({ input }) => { expectTypeOf(input).toEqualTypeOf<{ input: string }>() diff --git a/packages/msw/src/http-router-utils.test.ts b/packages/msw/src/http-router-utils.test.ts index 6e6507fb45..986e65c45b 100644 --- a/packages/msw/src/http-router-utils.test.ts +++ b/packages/msw/src/http-router-utils.test.ts @@ -1,12 +1,13 @@ -import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' import { oc } from '@orpc/contract' +import type { RouterClient } from '@orpc/server' import { os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { setupServer } from 'msw/node' -import z from 'zod' -import { createHTTPUtils } from './http-router-utils' +import * as z from 'zod' + +import { createHTTPUtils } from './http-router-utils.ts' const contract = { ping: oc.input(z.object({ value: z.number() })).output(z.object({ value: z.number() })), @@ -16,7 +17,7 @@ const contract = { } it('mirrors the router-contract shape', () => { - const utils = createHTTPUtils(contract, { handler: router => new RPCHandler(router) }) + const utils = createHTTPUtils(contract, { handler: (router) => new RPCHandler(router) }) expect(utils.ping.handler).toBeTypeOf('function') expect(utils.ping.error).toBeTypeOf('function') @@ -26,7 +27,7 @@ it('mirrors the router-contract shape', () => { it('keeps non-router values as-is', () => { const utils: any = createHTTPUtils({ ping: contract.ping, invalid: null } as any, { - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) expect(utils.invalid).toBeNull() @@ -51,15 +52,17 @@ describe('with an implemented router', () => { const utils = createHTTPUtils(router, { origin: 'http://localhost:3000', prefix: '/rpc', - handler: router => new RPCHandler(router), + handler: (router) => new RPCHandler(router), }) server.use(utils.ping.handler(({ input }) => ({ value: input.value * 2 }))) - const client: RouterClient = createORPCClient(new RPCLink({ - origin: 'http://localhost:3000', - url: '/rpc', - })) + const client: RouterClient = createORPCClient( + new RPCLink({ + origin: 'http://localhost:3000', + url: '/rpc', + }), + ) await expect(client.ping({ value: 21 })).resolves.toEqual({ value: 42 }) }) diff --git a/packages/msw/src/http-router-utils.ts b/packages/msw/src/http-router-utils.ts index 18e2c07a8a..69f16db672 100644 --- a/packages/msw/src/http-router-utils.ts +++ b/packages/msw/src/http-router-utils.ts @@ -1,12 +1,13 @@ import type { RouterContract } from '@orpc/contract' -import type { Context } from '@orpc/server' -import type { HTTPProcedureUtilsOptions } from './http-procedure-utils' import { ProcedureContract } from '@orpc/contract' +import type { Context } from '@orpc/server' import { isTypescriptObject } from '@orpc/shared' -import { HTTPProcedureUtils } from './http-procedure-utils' -export type HTTPRouterUtils - = T extends ProcedureContract +import type { HTTPProcedureUtilsOptions } from './http-procedure-utils.ts' +import { HTTPProcedureUtils } from './http-procedure-utils.ts' + +export type HTTPRouterUtils = + T extends ProcedureContract ? HTTPProcedureUtils : { [K in keyof T]: T[K] extends RouterContract ? HTTPRouterUtils : never diff --git a/packages/msw/src/index.test.ts b/packages/msw/src/index.test.ts index d72bfb7e38..2c78937f41 100644 --- a/packages/msw/src/index.test.ts +++ b/packages/msw/src/index.test.ts @@ -1,5 +1,5 @@ it('exports createHTTPUtils', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ createHTTPUtils: expect.any(Function), }) }) diff --git a/packages/msw/src/index.ts b/packages/msw/src/index.ts index 48c94b2f53..11d762a62c 100644 --- a/packages/msw/src/index.ts +++ b/packages/msw/src/index.ts @@ -1,3 +1,3 @@ -export { HTTPProcedureUtils } from './http-procedure-utils' -export type { HTTPProcedureUtilsOptions } from './http-procedure-utils' -export * from './http-router-utils' +export { HTTPProcedureUtils } from './http-procedure-utils.ts' +export type { HTTPProcedureUtilsOptions } from './http-procedure-utils.ts' +export * from './http-router-utils.ts' diff --git a/packages/msw/tsconfig.json b/packages/msw/tsconfig.json index 7e28f114fd..9979a0715c 100644 --- a/packages/msw/tsconfig.json +++ b/packages/msw/tsconfig.json @@ -1,18 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../contract" }, - { "path": "../openapi" }, - { "path": "../server" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/nest/README.md b/packages/nest/README.md index 758a4fbdbf..3f7fceffa5 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/nest/build.config.ts b/packages/nest/build.config.ts deleted file mode 100644 index 7646d90606..0000000000 --- a/packages/nest/build.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - oxc: { - decorator: { - legacy: true, - }, - }, - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/nest/package.json b/packages/nest/package.json index 82ce7263b1..7b6a2692e8 100644 --- a/packages/nest/package.json +++ b/packages/nest/package.json @@ -1,69 +1,49 @@ { "name": "@orpc/nest", - "type": "module", "version": "2.0.0-beta.48", "description": "NestJS integration for oRPC: implement typesafe contracts in NestJS controllers", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "contract-first", + "nest", + "nestjs", + "openapi", + "orpc", + "rpc", + "typesafe", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/nest" }, - "keywords": [ - "orpc", - "nestjs", - "nest", - "contract-first", - "rpc", - "openapi", - "typescript", - "typesafe" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "@nestjs/common": ">=11.1.27", - "@nestjs/core": ">=11.1.27", - "@nestjs/platform-node": ">=12.3.0", - "express": ">=5.2.1", - "fastify": ">=5.8.5", - "rxjs": ">=7.8.2" - }, - "peerDependenciesMeta": { - "@nestjs/platform-node": { - "optional": true - }, - "express": { - "optional": true - }, - "fastify": { - "optional": true - } + "prepack": "tsc -p tsconfig.build.json" }, "dependencies": { "@orpc/client": "workspace:*", @@ -87,5 +67,24 @@ "express": "^5.3.0", "fastify": "^5.12.8", "rxjs": "^7.8.2" + }, + "peerDependencies": { + "@nestjs/common": ">=11.1.27", + "@nestjs/core": ">=11.1.27", + "@nestjs/platform-node": ">=12.3.0", + "express": ">=5.2.1", + "fastify": ">=5.8.5", + "rxjs": ">=7.8.2" + }, + "peerDependenciesMeta": { + "@nestjs/platform-node": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + } } } diff --git a/packages/nest/src/constants.ts b/packages/nest/src/constants.ts index 4346278207..b5ca1f5331 100644 --- a/packages/nest/src/constants.ts +++ b/packages/nest/src/constants.ts @@ -1 +1,2 @@ -export const ORPC_MODULE_CONFIG_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for('ORPC_NEST_MODULE_CONFIG') +export const ORPC_MODULE_CONFIG_SYMBOL: unique symbol = + /* @__PURE__ */ Symbol.for('ORPC_NEST_MODULE_CONFIG') diff --git a/packages/nest/src/implement.test-d.ts b/packages/nest/src/implement.test-d.ts index 37aeb39ac0..0e9a704580 100644 --- a/packages/nest/src/implement.test-d.ts +++ b/packages/nest/src/implement.test-d.ts @@ -2,11 +2,12 @@ import { oc } from '@orpc/contract' import { implement, os } from '@orpc/server' import { describe, it } from 'vitest' import { z } from 'zod' -import { Implement } from './implement' -const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) +import { Implement } from './implement.ts' -const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) +const inputSchema = z.object({ input: z.number().transform((n) => `${n}`) }) + +const outputSchema = z.object({ output: z.number().transform((n) => `${n}`) }) describe('@Implement', () => { it('require return an implemented procedure that satisfy default initial context', () => { @@ -20,7 +21,9 @@ describe('@Implement', () => { @Implement(contract) ping_with_middleware_context() { - return implement(contract).use(({ next }) => next({ context: { extra: 'value' } })).handler(() => ({}) as any) + return implement(contract) + .use(({ next }) => next({ context: { extra: 'value' } })) + .handler(() => ({}) as any) } // @ts-expect-error --- return invalid @@ -32,7 +35,9 @@ describe('@Implement', () => { // @ts-expect-error --- initial context is not allowed @Implement(contract) ping_invalid_initial_context() { - return implement(contract).$context<{ a: string }>().handler(() => ({}) as any) + return implement(contract) + .$context<{ a: string }>() + .handler(() => ({}) as any) } // @ts-expect-error --- implement wrong contract @@ -59,14 +64,18 @@ describe('@Implement', () => { @Implement(contract) ping_with_middleware_context() { return { - ping: implement(contract.ping).use(({ next }) => next({ context: { extra: 'value' } })).handler(() => ({}) as any), + ping: implement(contract.ping) + .use(({ next }) => next({ context: { extra: 'value' } })) + .handler(() => ({}) as any), } } @Implement(contract) ping_with_lazy() { return { - ping: os.lazy(() => Promise.resolve({ default: implement(contract.ping).handler(() => ({}) as any) })), + ping: os.lazy(() => + Promise.resolve({ default: implement(contract.ping).handler(() => ({}) as any) }), + ), } } @@ -80,7 +89,9 @@ describe('@Implement', () => { @Implement(contract) ping_invalid_initial_context() { return { - ping: implement(contract.ping).$context<{ a: string }>().handler(() => ({}) as any), + ping: implement(contract.ping) + .$context<{ a: string }>() + .handler(() => ({}) as any), } } @@ -88,7 +99,13 @@ describe('@Implement', () => { @Implement(contract) ping_invalid_initial_context_lazy() { return { - ping: os.lazy(() => Promise.resolve({ default: implement(contract.ping).$context<{ a: string }>().handler(() => ({}) as any) })), + ping: os.lazy(() => + Promise.resolve({ + default: implement(contract.ping) + .$context<{ a: string }>() + .handler(() => ({}) as any), + }), + ), } } @@ -104,7 +121,11 @@ describe('@Implement', () => { @Implement(contract) ping_wrong_implement_lazy() { return { - ping: os.lazy(() => Promise.resolve({ default: implement(oc.input(inputSchema)).handler(() => ({}) as any) })), + ping: os.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 index abeabc707f..59e8d70993 100644 --- a/packages/nest/src/implement.test.ts +++ b/packages/nest/src/implement.test.ts @@ -1,12 +1,19 @@ -import type { CallHandler, CanActivate, ExecutionContext, NestInterceptor } from '@nestjs/common' -import type { Request as ExpressRequest } from 'express' -import type { FastifyReply } from 'fastify' -import type { NestStandardLazyRequest } from './module' import { Buffer } from 'node:buffer' import { once } from 'node:events' import { request as httpRequest } from 'node:http' + import FastifyCookie from '@fastify/cookie' -import { Controller, HttpException, Req, Res, SetMetadata, StreamableFile, UseGuards, UseInterceptors } from '@nestjs/common' +import type { CallHandler, CanActivate, ExecutionContext, NestInterceptor } from '@nestjs/common' +import { + Controller, + HttpException, + Req, + Res, + SetMetadata, + StreamableFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common' import { Reflector } from '@nestjs/core' import { FastifyAdapter } from '@nestjs/platform-fastify' import { NodeAdapter } from '@nestjs/platform-node' @@ -16,12 +23,16 @@ import { openapi } from '@orpc/openapi' import { implement, ORPCError, os, Procedure } from '@orpc/server' import { RequestLimitHandlerPlugin } from '@orpc/server/plugins' import { AsyncIteratorClass, getOrBind } from '@orpc/shared' +import type { Request as ExpressRequest } from 'express' +import type { FastifyReply } from 'fastify' import { catchError, tap } from 'rxjs' import supertest from 'supertest' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import * as z from 'zod' -import { Implement } from './implement' -import { ORPCModule } from './module' + +import { Implement } from './implement.ts' +import type { NestStandardLazyRequest } from './module.ts' +import { ORPCModule } from './module.ts' beforeEach(() => { vi.clearAllMocks() @@ -29,9 +40,11 @@ beforeEach(() => { describe('requirements', () => { it('should throw if @Implement is used on a non-path contract', () => { - const contract = oc.meta(openapi({ - method: 'GET', - })) + const contract = oc.meta( + openapi({ + method: 'GET', + }), + ) expect(() => { @Controller() @@ -58,12 +71,14 @@ describe('requirements', () => { }) try { - const { Implement: ImplementWithoutQueryMethod } = await import('./implement') + const { Implement: ImplementWithoutQueryMethod } = await import('./implement.ts') - const contract = oc.meta(openapi({ - path: '/procedure', - method: 'QUERY', - })) + const contract = oc.meta( + openapi({ + path: '/procedure', + method: 'QUERY', + }), + ) expect(() => { @Controller() @@ -76,8 +91,7 @@ describe('requirements', () => { void ImplController }).toThrow(/does not support the 'QUERY' HTTP method/) - } - finally { + } finally { vi.doUnmock('@nestjs/common') vi.resetModules() } @@ -86,72 +100,83 @@ describe('requirements', () => { it.each([ ['express adapter', undefined], ['node adapter', new NodeAdapter()], - ] as const)('should support the QUERY HTTP method when QueryMethod is available (NestJS 11.2+) with %s', async (_, adapter) => { - const contract = oc - .meta(openapi({ - path: '/query-route', - method: 'QUERY', - })) - .input(z.object({ search: z.string() })) + ] as const)( + 'should support the QUERY HTTP method when QueryMethod is available (NestJS 11.2+) with %s', + async (_, adapter) => { + const contract = oc + .meta( + openapi({ + path: '/query-route', + method: 'QUERY', + }), + ) + .input(z.object({ search: z.string() })) - @Controller() - class QueryController { - @Implement(contract) - queryRoute() { - return implement(contract).handler(({ input }) => `query: ${input.search}`) + @Controller() + class QueryController { + @Implement(contract) + queryRoute() { + return implement(contract).handler(({ input }) => `query: ${input.search}`) + } } - } - const moduleRef = await Test.createTestingModule({ - controllers: [QueryController], - }).compile() + const moduleRef = await Test.createTestingModule({ + controllers: [QueryController], + }).compile() - const app = moduleRef.createNestApplication(adapter as any) - await app.init() - await app.listen(0) + const app = moduleRef.createNestApplication(adapter as any) + await app.init() + await app.listen(0) + + try { + const server = app.getHttpServer() + const port = (server.address() as { port: number }).port + const payload = JSON.stringify({ search: 'earth' }) + + const res = await new Promise<{ statusCode: number; body: unknown }>((resolve, reject) => { + const req = httpRequest( + { + hostname: '127.0.0.1', + port, + path: '/query-route', + method: 'QUERY', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + }, + }, + (response) => { + let data = '' + response.on('data', (chunk: Buffer) => { + data += chunk + }) + response.on('end', () => + resolve({ statusCode: response.statusCode!, body: JSON.parse(data) }), + ) + }, + ) - try { - const server = app.getHttpServer() - const port = (server.address() as { port: number }).port - const payload = JSON.stringify({ search: 'earth' }) - - const res = await new Promise<{ statusCode: number, body: unknown }>((resolve, reject) => { - const req = httpRequest({ - hostname: '127.0.0.1', - port, - path: '/query-route', - method: 'QUERY', - headers: { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(payload), - }, - }, (response) => { - let data = '' - response.on('data', (chunk: Buffer) => { - data += chunk - }) - response.on('end', () => resolve({ statusCode: response.statusCode!, body: JSON.parse(data) })) + req.on('error', reject) + req.write(payload) + req.end() }) - req.on('error', reject) - req.write(payload) - req.end() - }) - - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('query: earth') - } - finally { - await app.close() - } - }) + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual('query: earth') + } finally { + await app.close() + } + }, + ) it('should support the QUERY HTTP method with the Fastify adapter', async () => { const contract = oc - .meta(openapi({ - path: '/query-route', - method: 'QUERY', - })) + .meta( + openapi({ + path: '/query-route', + method: 'QUERY', + }), + ) .input(z.object({ search: z.string() })) @Controller() @@ -171,25 +196,29 @@ describe('requirements', () => { await app.getHttpAdapter().getInstance().ready() try { - const res = await app.getHttpAdapter().getInstance().inject({ - method: 'QUERY', - url: '/query-route', - headers: { 'content-type': 'application/json' }, - payload: { search: 'earth' }, - }) + const res = await app + .getHttpAdapter() + .getInstance() + .inject({ + method: 'QUERY', + url: '/query-route', + headers: { 'content-type': 'application/json' }, + payload: { search: 'earth' }, + }) expect(res.statusCode).toEqual(200) expect(JSON.parse(res.body)).toEqual('query: earth') - } - finally { + } finally { await app.close() } }) it('should error if implemented method return invalid procedure', async () => { - const contract = oc.meta(openapi({ - path: '/procedure', - })) + const contract = oc.meta( + openapi({ + path: '/procedure', + }), + ) @Controller() class ImplController { @@ -212,9 +241,11 @@ describe('requirements', () => { it('should error if nestjs handler return mismatch result', async () => { const routingInterceptor = vi.fn(({ next }) => next()) - const contract = oc.meta(openapi({ - path: '/procedure', - })) + const contract = oc.meta( + openapi({ + path: '/procedure', + }), + ) @Controller() class ImplController { @@ -243,9 +274,11 @@ describe('requirements', () => { it('should error if a routing interceptor replaces the context without spreading it', async () => { const handler = vi.fn() - const contract = oc.meta(openapi({ - path: '/procedure', - })) + const contract = oc.meta( + openapi({ + path: '/procedure', + }), + ) @Controller() class ImplController { @@ -275,46 +308,70 @@ describe('requirements', () => { describe('routing', () => { const contract = { - staticPath: oc.meta(openapi({ - path: '/static/path', - method: 'GET', - })), - - dynamicPath: oc.meta(openapi({ - path: '/dynamic/{param}', - method: 'GET', - })).input(z.object({ param: z.string() })), - - restPath: oc.meta(openapi({ - path: '/rest/{+rest}', - method: 'GET', - })).input(z.object({ rest: z.string() })), - - prefixedPath: oc.meta(openapi({ - path: '/static', - prefix: '/prefix', - })), - - dynamicPrefix: oc.meta(openapi({ - path: '/static', - prefix: '/dynamic-prefix/{prefix}', - })).input(z.object({ prefix: z.string() })), - - styledParams: oc.meta(openapi({ - path: '/{params}', - paramsStyles: { - params: 'comma-delimited-array', - }, - })).input(z.object({ params: z.array(z.string()) })), - - mixed201Path: oc.meta(openapi({ - path: '/{param}/{+rest}', - prefix: '/mixed/{prefixes}', - successStatus: 201, - paramsStyles: { - prefixes: 'comma-delimited-array', - }, - })).input(z.object({ prefixes: z.array(z.string()), param: z.string(), rest: z.string() })), + staticPath: oc.meta( + openapi({ + path: '/static/path', + method: 'GET', + }), + ), + + dynamicPath: oc + .meta( + openapi({ + path: '/dynamic/{param}', + method: 'GET', + }), + ) + .input(z.object({ param: z.string() })), + + restPath: oc + .meta( + openapi({ + path: '/rest/{+rest}', + method: 'GET', + }), + ) + .input(z.object({ rest: z.string() })), + + prefixedPath: oc.meta( + openapi({ + path: '/static', + prefix: '/prefix', + }), + ), + + dynamicPrefix: oc + .meta( + openapi({ + path: '/static', + prefix: '/dynamic-prefix/{prefix}', + }), + ) + .input(z.object({ prefix: z.string() })), + + styledParams: oc + .meta( + openapi({ + path: '/{params}', + paramsStyles: { + params: 'comma-delimited-array', + }, + }), + ) + .input(z.object({ params: z.array(z.string()) })), + + mixed201Path: oc + .meta( + openapi({ + path: '/{param}/{+rest}', + prefix: '/mixed/{prefixes}', + successStatus: 201, + paramsStyles: { + prefixes: 'comma-delimited-array', + }, + }), + ) + .input(z.object({ prefixes: z.array(z.string()), param: z.string(), rest: z.string() })), } @Controller() @@ -346,12 +403,17 @@ describe('routing', () => { @Implement(contract.styledParams) styledParams() { - return implement(contract.styledParams).handler(({ input }) => `params: ${input.params}`) + return implement(contract.styledParams).handler( + ({ input }) => `params: ${String(input.params)}`, + ) } @Implement(contract.mixed201Path) mixed201Path() { - return implement(contract.mixed201Path).handler(({ input }) => `prefixes: ${input.prefixes} param: ${input.param}, rest: ${input.rest}`) + return implement(contract.mixed201Path).handler( + ({ input }) => + `prefixes: ${String(input.prefixes)} param: ${input.param}, rest: ${input.rest}`, + ) } } @@ -361,12 +423,21 @@ describe('routing', () => { router() { return { staticPath: implement(contract.staticPath).handler(() => 'static'), - dynamicPath: implement(contract.dynamicPath).handler(({ input }) => `param: ${input.param}`), + dynamicPath: implement(contract.dynamicPath).handler( + ({ input }) => `param: ${input.param}`, + ), restPath: implement(contract.restPath).handler(({ input }) => `rest: ${input.rest}`), prefixedPath: implement(contract.prefixedPath).handler(() => `prefixed path`), - dynamicPrefix: implement(contract.dynamicPrefix).handler(({ input }) => `prefix: ${input.prefix}`), - styledParams: implement(contract.styledParams).handler(({ input }) => `params: ${input.params}`), - mixed201Path: implement(contract.mixed201Path).handler(({ input }) => `prefixes: ${input.prefixes} param: ${input.param}, rest: ${input.rest}`), + dynamicPrefix: implement(contract.dynamicPrefix).handler( + ({ input }) => `prefix: ${input.prefix}`, + ), + styledParams: implement(contract.styledParams).handler( + ({ input }) => `params: ${String(input.params)}`, + ), + mixed201Path: implement(contract.mixed201Path).handler( + ({ input }) => + `prefixes: ${String(input.prefixes)} param: ${input.param}, rest: ${input.rest}`, + ), } } } @@ -463,25 +534,41 @@ describe('routing', () => { ['node adapter', new NodeAdapter()], ] as const)('params edge cases with %s', async (_, adapter) => { const contract = { - staticPath: oc.meta(openapi({ - path: '/static', - method: 'GET', - })).input(z.object({ tenant: z.string() })), + staticPath: oc + .meta( + openapi({ + path: '/static', + method: 'GET', + }), + ) + .input(z.object({ tenant: z.string() })), - dynamicPath: oc.meta(openapi({ - path: '/dynamic/{id}', - method: 'GET', - })).input(z.object({ tenant: z.string(), id: z.string() })), + dynamicPath: oc + .meta( + openapi({ + path: '/dynamic/{id}', + method: 'GET', + }), + ) + .input(z.object({ tenant: z.string(), id: z.string() })), - restPath: oc.meta(openapi({ - path: '/rest/{+rest}', - method: 'GET', - })).input(z.object({ tenant: z.string(), rest: z.string() })), + restPath: oc + .meta( + openapi({ + path: '/rest/{+rest}', + method: 'GET', + }), + ) + .input(z.object({ tenant: z.string(), rest: z.string() })), - pathNamedParam: oc.meta(openapi({ - path: '/files/{path}', - method: 'GET', - })).input(z.object({ tenant: z.string(), path: z.string() })), + pathNamedParam: oc + .meta( + openapi({ + path: '/files/{path}', + method: 'GET', + }), + ) + .input(z.object({ tenant: z.string(), path: z.string() })), } @Controller('/:tenant') @@ -507,10 +594,12 @@ describe('routing', () => { } } - const protoContract = oc.meta(openapi({ - path: '/proto/{+__proto__}', - inputStructure: 'detailed', - })) + const protoContract = oc.meta( + openapi({ + path: '/proto/{+__proto__}', + inputStructure: 'detailed', + }), + ) @Controller() class ProtoController { @@ -615,20 +704,18 @@ describe('response status, headers and body should follow standard-server', () = const routingInterceptor = vi.fn(({ next }) => next()) const moduleRef = await Test.createTestingModule({ controllers: [ImplController], - imports: [ - ORPCModule.forRoot({ routingInterceptors: [routingInterceptor] }), - ], + imports: [ORPCModule.forRoot({ routingInterceptors: [routingInterceptor] })], }).compile() const returnedValueSPy = vi.fn() const app = moduleRef.createNestApplication(adapter as any) - app.useGlobalInterceptors(new class implements NestInterceptor { - intercept(ctx: ExecutionContext, next: CallHandler) { - return next.handle().pipe( - tap(value => returnedValueSPy(value)), - ) - } - }()) + app.useGlobalInterceptors( + new (class implements NestInterceptor { + intercept(ctx: ExecutionContext, next: CallHandler) { + return next.handle().pipe(tap((value) => returnedValueSPy(value))) + } + })(), + ) await app.listen(0, '127.0.0.1') afterAll(async () => { @@ -641,9 +728,7 @@ describe('response status, headers and body should follow standard-server', () = const charset = adapter instanceof NodeAdapter ? '' : '; charset=utf-8' it('should response with output status', async () => { - handler - .mockResolvedValueOnce({ status: 202 }) - .mockResolvedValueOnce({ status: 203 }) + handler.mockResolvedValueOnce({ status: 202 }).mockResolvedValueOnce({ status: 203 }) await expect(supertest(httpServer).post('/response')).resolves.toSatisfy((res) => { expect(res.statusCode).toEqual(202) @@ -657,11 +742,13 @@ describe('response status, headers and body should follow standard-server', () = }) it('should response with output headers', async () => { - handler.mockResolvedValueOnce({ headers: { - 'x-custom': 'value', - 'set-cookie': ['cookie1=value1', 'cookie2=value2'], - 'x-undefined': undefined, - } }) + handler.mockResolvedValueOnce({ + headers: { + 'x-custom': 'value', + 'set-cookie': ['cookie1=value1', 'cookie2=value2'], + 'x-undefined': undefined, + }, + }) await expect(supertest(httpServer).post('/response')).resolves.toSatisfy((res) => { expect(res.statusCode).toEqual(200) @@ -865,7 +952,9 @@ describe('response status, headers and body should follow standard-server', () = expect(res.headers['content-type']).toMatch(/^multipart\/form-data; /) expect(res.body).toSatisfy(Buffer.isBuffer) - const _res = new Response(res.body, { headers: { 'Content-Type': res.headers['content-type'] } }) + const _res = new Response(res.body, { + headers: { 'Content-Type': res.headers['content-type'] }, + }) const form = await _res.formData() expect(form.get('number')).toEqual('1') @@ -875,14 +964,16 @@ describe('response status, headers and body should follow standard-server', () = }) it('should stream ReadableStream and can override content-type', async () => { - handler.mockResolvedValueOnce({ body: new ReadableStream({ - async start(controller) { - controller.enqueue(new TextEncoder().encode('chunk1')) - controller.enqueue(new TextEncoder().encode(' chunk2')) - controller.enqueue(new TextEncoder().encode(' chunk3')) - controller.close() - }, - }) }) + handler.mockResolvedValueOnce({ + body: new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('chunk1')) + controller.enqueue(new TextEncoder().encode(' chunk2')) + controller.enqueue(new TextEncoder().encode(' chunk3')) + controller.close() + }, + }), + }) await expect(supertest(httpServer).post('/response')).resolves.toSatisfy((res) => { expect(res.statusCode).toEqual(200) @@ -924,11 +1015,13 @@ describe('response status, headers and body should follow standard-server', () = }) it('should stream event stream', async () => { - handler.mockResolvedValueOnce({ body: (async function* () { - yield 'chunk1' - yield 'chunk2' - yield 'chunk3' - }()) }) + handler.mockResolvedValueOnce({ + body: (async function* () { + yield 'chunk1' + yield 'chunk2' + yield 'chunk3' + })(), + }) const res = await supertest(httpServer).post('/response').responseType('blob') @@ -957,38 +1050,71 @@ describe('response status, headers and body should follow standard-server', () = it.each([ { name: 'ReadableStream', - response: () => ({ headers: { ...headers, 'content-length': '6' }, body: new Blob(['chunk1']).stream() }), - expected: { 'standard-server': 'json', 'content-type': 'text/plain', 'content-length': '6' }, + response: () => ({ + headers: { ...headers, 'content-length': '6' }, + body: new Blob(['chunk1']).stream(), + }), + expected: { + 'standard-server': 'json', + 'content-type': 'text/plain', + 'content-length': '6', + }, text: 'chunk1', }, { name: 'Blob', - response: () => ({ headers, body: new Blob(['blob content'], { type: 'application/pdf' }) }), - expected: { 'standard-server': 'json', 'content-type': 'text/plain', 'content-length': '12' }, + response: () => ({ + headers, + body: new Blob(['blob content'], { type: 'application/pdf' }), + }), + expected: { + 'standard-server': 'json', + 'content-type': 'text/plain', + 'content-length': '12', + }, text: 'blob content', }, { name: 'undefined', response: () => ({ headers, body: undefined }), - expected: { 'standard-server': undefined, 'content-type': undefined, 'content-length': '0' }, + expected: { + 'standard-server': undefined, + 'content-type': undefined, + 'content-length': '0', + }, text: '', }, { name: 'JSON', response: () => ({ headers, body: { a: 1 } }), - expected: { 'standard-server': 'json', 'content-type': expect.stringMatching(/^text\/plain/), 'content-length': '7' }, + expected: { + 'standard-server': 'json', + 'content-type': expect.stringMatching(/^text\/plain/), + 'content-length': '7', + }, text: '{"a":1}', }, { name: 'JSON with a JSON content-type', - response: () => ({ headers: { ...headers, 'content-type': 'application/json' }, body: { a: 1 } }), - expected: { 'standard-server': undefined, 'content-type': `application/json${charset}`, 'content-length': '7' }, + response: () => ({ + headers: { ...headers, 'content-type': 'application/json' }, + body: { a: 1 }, + }), + expected: { + 'standard-server': undefined, + 'content-type': `application/json${charset}`, + 'content-length': '7', + }, text: '{"a":1}', }, { name: 'URLSearchParams', response: () => ({ headers, body: new URLSearchParams('a=4') }), - expected: { 'standard-server': 'url-search-params', 'content-type': expect.stringMatching(/^text\/plain/), 'content-length': '3' }, + expected: { + 'standard-server': 'url-search-params', + 'content-type': expect.stringMatching(/^text\/plain/), + 'content-length': '3', + }, text: 'a=4', }, { @@ -998,17 +1124,33 @@ describe('response status, headers and body should follow standard-server', () = form.set('number', '1') return { headers, body: form } }, - expected: { 'standard-server': undefined, 'content-type': expect.stringMatching(/^multipart\/form-data; /), 'content-length': undefined }, + expected: { + 'standard-server': undefined, + 'content-type': expect.stringMatching(/^multipart\/form-data; /), + 'content-length': undefined, + }, text: expect.stringContaining('name="number"'), }, { name: 'event stream', - response: () => ({ headers, body: (async function* () { yield 'chunk1' }()) }), - expected: { 'standard-server': 'event-stream', 'content-type': 'text/plain', 'content-length': undefined }, + response: () => ({ + headers, + body: (async function* () { + yield 'chunk1' + })(), + }), + expected: { + 'standard-server': 'event-stream', + 'content-type': 'text/plain', + 'content-length': undefined, + }, text: expect.stringContaining('chunk1'), }, ])('for $name body', async ({ response, expected, text }) => { - routingInterceptor.mockResolvedValueOnce({ matched: true, response: { status: 200, ...response() } }) + routingInterceptor.mockResolvedValueOnce({ + matched: true, + response: { status: 200, ...response() }, + }) const res = await supertest(httpServer).post('/response').responseType('blob') @@ -1059,7 +1201,7 @@ describe('response status, headers and body should follow standard-server', () = it('when the client disconnects before the handler returns', async () => { const cleanup = vi.fn() handler.mockImplementationOnce(async ({ signal }) => { - await new Promise(resolve => signal?.addEventListener('abort', resolve, { once: true })) + await new Promise((resolve) => signal?.addEventListener('abort', resolve, { once: true })) return { body: createPendingIterator(cleanup) } }) @@ -1074,9 +1216,11 @@ describe('response status, headers and body should follow standard-server', () = }) describe('error handling', () => { - const contract = oc.meta(openapi({ - path: '/error', - })) + const contract = oc.meta( + openapi({ + path: '/error', + }), + ) const handler = vi.fn() @@ -1107,13 +1251,13 @@ describe('error handling', () => { throw error }) const app = moduleRef.createNestApplication(adapter as any) - app.useGlobalInterceptors(new class implements NestInterceptor { - intercept(ctx: ExecutionContext, next: CallHandler) { - return next.handle().pipe( - catchError(error => catchErrorSpy(error)), - ) - } - }()) + app.useGlobalInterceptors( + new (class implements NestInterceptor { + intercept(ctx: ExecutionContext, next: CallHandler) { + return next.handle().pipe(catchError((error) => catchErrorSpy(error))) + } + })(), + ) await app.init() if (adapter instanceof FastifyAdapter) { @@ -1156,56 +1300,67 @@ describe('error handling', () => { { name: 'without', headers: {}, - expected: { 'standard-server': undefined, 'content-type': 'application/json; charset=utf-8' }, + expected: { + 'standard-server': undefined, + 'content-type': 'application/json; charset=utf-8', + }, }, { name: 'with', headers: { 'content-type': 'application/problem+json' }, - expected: { 'standard-server': 'json', 'content-type': `application/problem+json${charset}` }, + expected: { + 'standard-server': 'json', + 'content-type': `application/problem+json${charset}`, + }, }, - ])('should respect or override response headers of HttpException $name a content-type', async ({ headers, expected }) => { - routingInterceptor.mockResolvedValueOnce({ - matched: true, - response: { - status: 400, - headers: { - 'x-custom': 'value', - 'standard-server': 'file', - 'content-length': '999', - ...headers, + ])( + 'should respect or override response headers of HttpException $name a content-type', + async ({ headers, expected }) => { + routingInterceptor.mockResolvedValueOnce({ + matched: true, + response: { + status: 400, + headers: { + 'x-custom': 'value', + 'standard-server': 'file', + 'content-length': '999', + ...headers, + }, + body: { code: 'BAD_REQUEST' }, }, - body: { code: 'BAD_REQUEST' }, - }, - }) + }) - const res = await supertest(httpServer).post('/error') + const res = await supertest(httpServer).post('/error') - expect(res.statusCode).toEqual(400) - expect(res.headers['x-custom']).toEqual('value') - expect(res.headers['standard-server']).toEqual(expected['standard-server']) - expect(res.headers['content-type']).toEqual(expected['content-type']) - expect(res.headers['content-length']).toEqual('22') - expect(res.body).toEqual({ code: 'BAD_REQUEST' }) + expect(res.statusCode).toEqual(400) + expect(res.headers['x-custom']).toEqual('value') + expect(res.headers['standard-server']).toEqual(expected['standard-server']) + expect(res.headers['content-type']).toEqual(expected['content-type']) + expect(res.headers['content-length']).toEqual('22') + expect(res.body).toEqual({ code: 'BAD_REQUEST' }) - expect(catchErrorSpy).toHaveBeenCalledWith(new HttpException({ code: 'BAD_REQUEST' }, 400)) - }) + expect(catchErrorSpy).toHaveBeenCalledWith(new HttpException({ code: 'BAD_REQUEST' }, 400)) + }, + ) it('should not throw for special responses because HttpException only accepts JSON objects', async () => { - routingInterceptor.mockResolvedValueOnce({ - matched: true, - response: { - status: 502, - headers: {}, - body: new Blob(['test'], { type: 'text/plain' }), - }, - }).mockResolvedValueOnce({ - matched: true, - response: { - status: 502, - headers: {}, - body: 'text', - }, - }) + routingInterceptor + .mockResolvedValueOnce({ + matched: true, + response: { + status: 502, + headers: {}, + body: new Blob(['test'], { type: 'text/plain' }), + }, + }) + .mockResolvedValueOnce({ + matched: true, + response: { + status: 502, + headers: {}, + body: 'text', + }, + }) await expect(supertest(httpServer).post('/error')).resolves.toSatisfy((res) => { expect(res.statusCode).toEqual(502) @@ -1230,9 +1385,11 @@ describe('error handling', () => { describe('compatibility', () => { it('procedure-based implementation controller can access injected dependencies', async () => { - const contract = oc.meta(openapi({ - path: '/injection', - })) + const contract = oc.meta( + openapi({ + path: '/injection', + }), + ) let req: ExpressRequest @@ -1261,9 +1418,11 @@ describe('compatibility', () => { }) it('router-based implementation controller can access injected dependencies', async () => { - const contract = oc.meta(openapi({ - path: '/injection', - })) + const contract = oc.meta( + openapi({ + path: '/injection', + }), + ) let req: ExpressRequest @@ -1300,16 +1459,21 @@ describe('compatibility', () => { } // custom decorator metadata keyed by method name, like some third-party decorators use - const Meta = (key: string): MethodDecorator => (target, propertyKey) => { - Reflect.defineMetadata(key, 'value', target, propertyKey) - } + const Meta = + (key: string): MethodDecorator => + (target, propertyKey) => { + Reflect.defineMetadata(key, 'value', target, propertyKey) + } const handlerMeta = vi.fn() class MetaGuard implements CanActivate { canActivate(ctx: ExecutionContext) { const reflector = new Reflector() - handlerMeta(reflector.get('above', ctx.getHandler()), reflector.get('below', ctx.getHandler())) + handlerMeta( + reflector.get('above', ctx.getHandler()), + reflector.get('below', ctx.getHandler()), + ) return true } } @@ -1419,8 +1583,20 @@ describe('compatibility', () => { expect((await supertest(app.getHttpServer()).post('/guarded/ping')).status).toBe(403) expect((await supertest(app.getHttpServer()).post('/guarded/pong')).status).toBe(403) - expect((await supertest(app.getHttpServer()).post('/guarded/ping').set('authorization', 'valid-token')).status).toBe(200) - expect((await supertest(app.getHttpServer()).post('/guarded/pong').set('authorization', 'valid-token')).status).toBe(200) + expect( + ( + await supertest(app.getHttpServer()) + .post('/guarded/ping') + .set('authorization', 'valid-token') + ).status, + ).toBe(200) + expect( + ( + await supertest(app.getHttpServer()) + .post('/guarded/pong') + .set('authorization', 'valid-token') + ).status, + ).toBe(200) expect(canActivate).toHaveBeenCalledTimes(4) }) @@ -1442,7 +1618,7 @@ describe('compatibility', () => { }) const intercept = vi.fn((ctx: ExecutionContext, next: CallHandler) => { - return next.handle().pipe(tap(value => intercepted.push(value))) + return next.handle().pipe(tap((value) => intercepted.push(value))) }) class SpyInterceptor implements NestInterceptor { @@ -1499,9 +1675,8 @@ describe('compatibility', () => { if (order === '@UseInterceptors above @Implement') { // evaluates after @Implement, so it runs inside ImplementInterceptor and observes the raw procedures expect(intercepted).toHaveLength(2) - intercepted.forEach(value => expect(value).toBeInstanceOf(Procedure)) - } - else { + intercepted.forEach((value) => expect(value).toBeInstanceOf(Procedure)) + } else { // evaluates before @Implement, so it runs outside ImplementInterceptor and observes the encoded responses expect(intercepted).toEqual(['"pong"', '"peng"']) } @@ -1519,7 +1694,7 @@ describe('compatibility', () => { }) const intercept = vi.fn((ctx: ExecutionContext, next: CallHandler) => { - return next.handle().pipe(tap(value => intercepted.push(value))) + return next.handle().pipe(tap((value) => intercepted.push(value))) }) class SpyInterceptor implements NestInterceptor { @@ -1566,8 +1741,7 @@ describe('compatibility', () => { // evaluates after @Implement, so it runs inside ImplementInterceptor and observes the raw procedure expect(intercepted).toHaveLength(1) expect(intercepted[0]).toBeInstanceOf(Procedure) - } - else { + } else { // evaluates before @Implement, so it runs outside ImplementInterceptor and observes the encoded response expect(intercepted).toEqual(['"pong"']) } @@ -1576,9 +1750,11 @@ describe('compatibility', () => { }) it('should support lazy router/procedure in router-based implementation controller', async () => { - const contract = oc.meta(openapi({ - path: '/lazy', - })) + const contract = oc.meta( + openapi({ + path: '/lazy', + }), + ) @Controller() class ImplController { @@ -1587,9 +1763,11 @@ describe('compatibility', () => { return { lazy: os.lazy(async () => ({ default: { - contract: os.lazy(() => Promise.resolve({ - default: implement(contract).handler(() => { }), - })), + contract: os.lazy(() => + Promise.resolve({ + default: implement(contract).handler(() => {}), + }), + ), }, })), } @@ -1608,10 +1786,12 @@ describe('compatibility', () => { }) it('can custom request parser with toNestStandardLazyRequest option', async () => { - const contract = oc.meta(openapi({ - path: '/parser/{param}', - inputStructure: 'detailed', - })) + const contract = oc.meta( + openapi({ + path: '/parser/{param}', + inputStructure: 'detailed', + }), + ) const handler = vi.fn(({ input }) => input) @@ -1623,13 +1803,16 @@ describe('compatibility', () => { } } - const toNestStandardLazyRequest = vi.fn(() => ({ - url: '/test', - method: 'POST', - headers: {}, - resolveBody: async () => '__OVERRIDED__', - params: { param: '__PARAM__' }, - } satisfies NestStandardLazyRequest)) + const toNestStandardLazyRequest = vi.fn( + () => + ({ + url: '/test', + method: 'POST', + headers: {}, + resolveBody: async () => '__OVERRIDED__', + params: { param: '__PARAM__' }, + }) satisfies NestStandardLazyRequest, + ) const moduleRef = await Test.createTestingModule({ controllers: [ImplController], @@ -1661,10 +1844,12 @@ describe('compatibility', () => { }) it('ignores contract rest params when custom request parser provides no wildcard param', async () => { - const contract = oc.meta(openapi({ - path: '/parser-rest/{+rest}', - inputStructure: 'detailed', - })) + const contract = oc.meta( + openapi({ + path: '/parser-rest/{+rest}', + inputStructure: 'detailed', + }), + ) @Controller() class ImplController { @@ -1678,13 +1863,14 @@ describe('compatibility', () => { controllers: [ImplController], imports: [ ORPCModule.forRoot({ - toNestStandardLazyRequest: () => ({ - url: '/parser-rest/value', - method: 'POST', - headers: {}, - resolveBody: async () => undefined, - params: { other: '__OTHER__' }, - } satisfies NestStandardLazyRequest), + toNestStandardLazyRequest: () => + ({ + url: '/parser-rest/value', + method: 'POST', + headers: {}, + resolveBody: async () => undefined, + params: { other: '__OTHER__' }, + }) satisfies NestStandardLazyRequest, }), ], }).compile() @@ -1700,12 +1886,18 @@ describe('compatibility', () => { it('procedure path[] should use meta.path or fall back to empty', async () => { const contract = { - without: oc.meta(openapi({ - path: '/path/without', - })), - with: oc.meta(openapi({ - path: '/path/with', - })).meta(meta.path(['use', 'this', 'path'])), + without: oc.meta( + openapi({ + path: '/path/without', + }), + ), + with: oc + .meta( + openapi({ + path: '/path/with', + }), + ) + .meta(meta.path(['use', 'this', 'path'])), } @Controller() @@ -1743,14 +1935,21 @@ describe('compatibility', () => { expect(interceptor).toHaveBeenCalledTimes(2) expect(interceptor).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: [] })) - expect(interceptor).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: ['use', 'this', 'path'] })) + expect(interceptor).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ path: ['use', 'this', 'path'] }), + ) }) it('should work with Fastify cookie plugin', async () => { - const contract = oc.meta(openapi({ - path: '/cookie', - outputStructure: 'detailed', - })).input(z.object({ cookie: z.string() })) + const contract = oc + .meta( + openapi({ + path: '/cookie', + outputStructure: 'detailed', + }), + ) + .input(z.object({ cookie: z.string() })) @Controller() class ImplController { diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index f3c98182a2..f2df2cce6b 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -1,29 +1,66 @@ -import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common' -import type { NodeRequest, NodeResponse } from '@nestjs/platform-node' -import type { AnyProcedureContract, RouterContract } from '@orpc/contract' -import type { AnyProcedure, Context, ContractedRouter, DefaultInitialContext } from '@orpc/server' -import type { ResolvedStandardHandlerHandleOptions, StandardHandlerCodec, StandardHandlerCodecResolvedProcedure } from '@orpc/server/standard' -import type { Promisable } from '@orpc/shared' -import type { StandardBodyHint, StandardHeaders, StandardLazyRequest, StandardResponse } from '@standard-server/core' -import type { Request as ExpressRequest, Response as ExpressResponse } from 'express' -import type { FastifyReply, FastifyRequest } from 'fastify' -import type { Observable } from 'rxjs' -import type { NestStandardLazyRequest, ORPCModuleConfig } from './module' import { Readable } from 'node:stream' + +import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common' import * as NestCommon from '@nestjs/common' -import { applyDecorators, Delete, Get, Head, HttpCode, HttpException, Inject, Injectable, Optional, Options, Patch, Post, Put, StreamableFile, UseInterceptors } from '@nestjs/common' +import { + applyDecorators, + Delete, + Get, + Head, + HttpCode, + HttpException, + Inject, + Injectable, + Optional, + Options, + Patch, + Post, + Put, + StreamableFile, + UseInterceptors, +} from '@nestjs/common' import { HttpAdapterHost } from '@nestjs/core' +import type { NodeRequest, NodeResponse } from '@nestjs/platform-node' +import type { AnyProcedureContract, RouterContract } from '@orpc/contract' import { getPathMeta, ProcedureContract } from '@orpc/contract' import { DEFAULT_OPENAPI_METHOD, getDynamicPathParams, getOpenAPIMeta } from '@orpc/openapi' import { OpenAPIHandlerCodecCore } from '@orpc/openapi/standard' +import type { AnyProcedure, Context, ContractedRouter, DefaultInitialContext } from '@orpc/server' import { DEFAULT_SUCCESS_STATUS, getRouter, Procedure, unlazy } from '@orpc/server' +import type { + ResolvedStandardHandlerHandleOptions, + StandardHandlerCodec, + StandardHandlerCodecResolvedProcedure, +} from '@orpc/server/standard' import { StandardHandler } from '@orpc/server/standard' -import { isAsyncIteratorObject, mergeHttpPath, NullProtoObj, stringifyJSON, toArray, value } from '@orpc/shared' -import { flattenStandardHeader, generateContentDisposition, getRequiredStandardBodyHint } from '@standard-server/core' +import type { Promisable } from '@orpc/shared' +import { + isAsyncIteratorObject, + mergeHttpPath, + NullProtoObj, + stringifyJSON, + toArray, + value, +} from '@orpc/shared' +import type { + StandardBodyHint, + StandardHeaders, + StandardLazyRequest, + StandardResponse, +} from '@standard-server/core' +import { + flattenStandardHeader, + generateContentDisposition, + getRequiredStandardBodyHint, +} from '@standard-server/core' import { toEventStream, toStandardLazyRequest } from '@standard-server/node' +import type { Request as ExpressRequest, Response as ExpressResponse } from 'express' +import type { FastifyReply, FastifyRequest } from 'fastify' +import type { Observable } from 'rxjs' import { mergeMap } from 'rxjs' -import { ORPC_MODULE_CONFIG_SYMBOL } from './constants' +import { ORPC_MODULE_CONFIG_SYMBOL } from './constants.ts' +import type { NestStandardLazyRequest, ORPCModuleConfig } from './module.ts' // Namespace access so NestJS < 11.2 (no QueryMethod export) loads without a SyntaxError const QueryMethod = NestCommon.QueryMethod as typeof NestCommon.QueryMethod | undefined @@ -52,6 +89,7 @@ const MethodDecoratorMap = { */ export function Implement( contract: T, + // oxlint-disable-next-line typescript/no-unnecessary-type-parameters ): >>( target: Record, propertyKey: string, @@ -59,10 +97,11 @@ export function Implement( ) => void { if (contract instanceof ProcedureContract) { return (target, propertyKey, descriptor) => { - applyDecorators( - toNestRouteDecorator(contract), - UseInterceptors(ImplementInterceptor), - )(target, propertyKey, descriptor) + applyDecorators(toNestRouteDecorator(contract), UseInterceptors(ImplementInterceptor))( + target, + propertyKey, + descriptor, + ) } } @@ -98,16 +137,10 @@ function toNestRouteDecorator(contract: AnyProcedureContract): MethodDecorator { `) } - return applyDecorators( - QueryMethod(path), - HttpCode(successStatus), - ) + return applyDecorators(QueryMethod(path), HttpCode(successStatus)) } - return applyDecorators( - MethodDecoratorMap[method](path), - HttpCode(successStatus), - ) + return applyDecorators(MethodDecoratorMap[method](path), HttpCode(successStatus)) } function implementRouterContract( @@ -133,11 +166,21 @@ function implementRouterContract( queueMicrotask(() => { for (const p of Reflect.getOwnMetadataKeys(target, propertyKey)) { - Reflect.defineMetadata(p, Reflect.getOwnMetadata(p, target, propertyKey), target, methodName) + 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) + Reflect.defineMetadata( + p, + Reflect.getOwnMetadata(p, target.constructor, propertyKey), + target.constructor, + methodName, + ) } }) @@ -150,8 +193,7 @@ function implementRouterContract( queueMicrotask(() => { routeDecorator(target, methodName, childDescriptor) }) - } - else { + } else { implementRouterContract(childContract, target, methodName, childDescriptor) } } @@ -162,7 +204,10 @@ export class ImplementInterceptor implements NestInterceptor { private readonly config: ORPCModuleConfig private readonly codec: NestHandlerCodec private readonly handler: StandardHandler - private readonly toNestStandardLazyRequest: Exclude + private readonly toNestStandardLazyRequest: Exclude< + ORPCModuleConfig['toNestStandardLazyRequest'], + undefined + > private readonly httpAdapterHost: HttpAdapterHost constructor( @@ -170,29 +215,34 @@ export class ImplementInterceptor implements NestInterceptor { @Inject(HttpAdapterHost) httpAdapterHost: HttpAdapterHost, ) { // @Optional() does not allow set default value so we need to do it here - this.config = config ?? {} as ORPCModuleConfig + this.config = config ?? ({} as ORPCModuleConfig) this.httpAdapterHost = httpAdapterHost this.codec = new NestHandlerCodec(this.config) this.handler = new StandardHandler(this.codec, this.config) - this.toNestStandardLazyRequest = this.config.toNestStandardLazyRequest ?? ((req: ExpressRequest | FastifyRequest | NodeRequest, res: ExpressResponse | FastifyReply | NodeResponse) => { - const standardRequest: NestStandardLazyRequest = toStandardLazyRequest( - 'raw' in req ? req.raw : req, - 'raw' in res ? res.raw : res, - ) - - // if body already parsed by NestJS - if (req.body !== undefined) { - standardRequest.resolveBody = () => Promise.resolve(req.body) - } + this.toNestStandardLazyRequest = + this.config.toNestStandardLazyRequest ?? + (( + req: ExpressRequest | FastifyRequest | NodeRequest, + res: ExpressResponse | FastifyReply | NodeResponse, + ) => { + const standardRequest: NestStandardLazyRequest = toStandardLazyRequest( + 'raw' in req ? req.raw : req, + 'raw' in res ? res.raw : res, + ) + + // if body already parsed by NestJS + if (req.body !== undefined) { + standardRequest.resolveBody = () => Promise.resolve(req.body) + } - standardRequest.params = req.params as NestStandardLazyRequest['params'] + standardRequest.params = req.params as NestStandardLazyRequest['params'] - return standardRequest - }) + return standardRequest + }) } - intercept(ctx: ExecutionContext, next: CallHandler): Observable { + intercept(ctx: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( mergeMap(async (impl: unknown) => { const { default: procedure } = await unlazy(impl) @@ -208,7 +258,7 @@ export class ImplementInterceptor implements NestInterceptor { const standardRequest = this.toNestStandardLazyRequest(req, res) - const context = await value(this.config.context ?? {} as DefaultInitialContext, ctx) + const context = await value(this.config.context ?? ({} as DefaultInitialContext), ctx) const result = await this.handler.handle(standardRequest, { context: this.codec.withMatch(context, { procedure, params: standardRequest.params }), @@ -220,7 +270,11 @@ export class ImplementInterceptor implements NestInterceptor { ) } - const [nestBody, headers] = toNestHttpBody(result.response, standardRequest.signal, this.config.toNestResponse) + const [nestBody, headers] = toNestHttpBody( + result.response, + standardRequest.signal, + this.config.toNestResponse, + ) const httpAdapter = this.httpAdapterHost.httpAdapter @@ -230,8 +284,7 @@ export class ImplementInterceptor implements NestInterceptor { toArray(headers[key]).forEach((value, index) => { if (index === 0) { httpAdapter.setHeader(res, key, value) - } - else { + } else { httpAdapter.appendHeader(res, key, value) } }) @@ -267,7 +320,9 @@ function toNestHttpBody( if (body instanceof Blob) { headers['standard-server'] ??= 'file' satisfies StandardBodyHint headers['content-type'] ??= body.type - headers['content-disposition'] ??= generateContentDisposition(body instanceof File ? body.name : 'blob') + headers['content-disposition'] ??= generateContentDisposition( + body instanceof File ? body.name : 'blob', + ) // BunS3 can use NaN for the size if (Number.isFinite(body.size)) { @@ -304,12 +359,7 @@ function toNestHttpBody( } // Prefer throwing an HttpException for more native error handling in NestJS. - if ( - status >= 300 - && typeof body === 'object' - && body !== null - && !Array.isArray(body) - ) { + if (status >= 300 && typeof body === 'object' && body !== null && !Array.isArray(body)) { if (headers['content-type'] !== undefined) { headers['standard-server'] = getRequiredStandardBodyHint('json', headers) } @@ -319,7 +369,12 @@ function toNestHttpBody( headers['content-type'] ??= 'application/json' headers['standard-server'] = getRequiredStandardBodyHint('json', headers) - return [typeof body === 'string' || body === null || headers['standard-server'] !== undefined ? stringifyJSON(body) : body, headers] + return [ + typeof body === 'string' || body === null || headers['standard-server'] !== undefined + ? stringifyJSON(body) + : body, + headers, + ] } interface NestHandlerCodecMatch { @@ -327,14 +382,20 @@ interface NestHandlerCodecMatch { params: NestStandardLazyRequest['params'] } -class NestHandlerCodec extends OpenAPIHandlerCodecCore implements StandardHandlerCodec { +class NestHandlerCodec + extends OpenAPIHandlerCodecCore + implements StandardHandlerCodec +{ private readonly CONTEXT_SYMBOL = Symbol('ORPC_NEST_HANDLER_CODEC_CONTEXT') withMatch(context: T, match: NestHandlerCodecMatch): T { return { ...context, [this.CONTEXT_SYMBOL]: match } } - resolveProcedure(request: StandardLazyRequest, options: ResolvedStandardHandlerHandleOptions): StandardHandlerCodecResolvedProcedure | undefined { + resolveProcedure( + request: StandardLazyRequest, + options: ResolvedStandardHandlerHandleOptions, + ): StandardHandlerCodecResolvedProcedure | undefined { const match = options.context[this.CONTEXT_SYMBOL] as NestHandlerCodecMatch | undefined if (!match) { @@ -346,10 +407,14 @@ class NestHandlerCodec extends OpenAPIHandlerCodecCore imp return { path: getPathMeta(procedure) ?? [], procedure, - decodeInput: () => this.decodeInput({ - procedure, - params: toORPCOpenAPIParams(procedure, params), - }, request), + decodeInput: () => + this.decodeInput( + { + procedure, + params: toORPCOpenAPIParams(procedure, params), + }, + request, + ), } } } @@ -358,14 +423,17 @@ class NestHandlerCodec extends OpenAPIHandlerCodecCore imp * Destroys the stream once the request is aborted, because Nest's Express adapter only pipes a StreamableFile * and never destroys it on client disconnect, which would leave the underlying event iterator or stream uncanceled. */ -function toStreamableFile(stream: Readable, headers: StandardHeaders, signal: AbortSignal | undefined): StreamableFile { +function toStreamableFile( + stream: Readable, + headers: StandardHeaders, + signal: AbortSignal | undefined, +): StreamableFile { const type = flattenStandardHeader(headers['content-type']) headers['content-type'] = undefined if (signal?.aborted) { stream.destroy() - } - else { + } else { signal?.addEventListener('abort', () => stream.destroy(), { once: true }) } @@ -376,7 +444,10 @@ function flattenParamValue(value: string | string[]): string { return Array.isArray(value) ? value.join('/') : value } -function toORPCOpenAPIParams(contract: AnyProcedureContract, params: NestStandardLazyRequest['params']): undefined | Record { +function toORPCOpenAPIParams( + contract: AnyProcedureContract, + params: NestStandardLazyRequest['params'], +): undefined | Record { const meta = getOpenAPIMeta(contract) if (!params || meta?.path === undefined || Object.keys(params).length === 0) { @@ -392,7 +463,7 @@ function toORPCOpenAPIParams(contract: AnyProcedureContract, params: NestStandar if (key === restKey) { const restParams = getDynamicPathParams( meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path, - )?.filter(c => c.allowsSlash) + )?.filter((c) => c.allowsSlash) if (restParams?.length) { for (const c of restParams) { @@ -419,7 +490,10 @@ function toNestPattern(path: `/${string}`): `/${string}` { for (let i = params.length - 1; i >= 0; i--) { const param = params[i]! const pattern = param.allowsSlash ? `*` : `:${param.parameterName}` - path = path.slice(0, param.startIndex) + pattern + path.slice(param.startIndex + param.segment.length) + path = + path.slice(0, param.startIndex) + + pattern + + path.slice(param.startIndex + param.segment.length) } return path diff --git a/packages/nest/src/index.test.ts b/packages/nest/src/index.test.ts index fdee9fd290..cad67ab816 100644 --- a/packages/nest/src/index.test.ts +++ b/packages/nest/src/index.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' it('exports ORPCModule, Impl, Implement', async () => { - await expect(import('../src')).resolves.toMatchObject({ + await expect(import('../src/index.ts')).resolves.toMatchObject({ ORPCModule: expect.any(Function), Impl: expect.any(Function), Implement: expect.any(Function), diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts index 222720f5e7..a2a5be92e4 100644 --- a/packages/nest/src/index.ts +++ b/packages/nest/src/index.ts @@ -1,9 +1,9 @@ -export * from './constants' -export * from './implement' +export * from './constants.ts' +export * from './implement.ts' export { /** * @alias Implement */ Implement as Impl, -} from './implement' -export * from './module' +} from './implement.ts' +export * from './module.ts' diff --git a/packages/nest/src/module.test.ts b/packages/nest/src/module.test.ts index 0097e61f7f..8d18baf9c3 100644 --- a/packages/nest/src/module.test.ts +++ b/packages/nest/src/module.test.ts @@ -7,17 +7,20 @@ import { openapi } from '@orpc/openapi' import { implement } from '@orpc/server' import supertest from 'supertest' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Implement } from './implement' -import { ORPCModule } from './module' + +import { Implement } from './implement.ts' +import { ORPCModule } from './module.ts' beforeEach(() => { vi.clearAllMocks() }) describe('module configuration', () => { - const contract = oc.meta(openapi({ - path: '/module-config', - })) + const contract = oc.meta( + openapi({ + path: '/module-config', + }), + ) const handler = vi.fn(({ context }) => context.source) @@ -57,7 +60,7 @@ describe('module configuration', () => { it('should apply config from ORPCModule.forRootAsync with injections', async () => { const routingInterceptor = vi.fn(({ next }) => next()) - const useFactory = vi.fn(async request => ({ + const useFactory = vi.fn(async (request) => ({ context: { source: 'forRootAsync', }, @@ -80,7 +83,9 @@ describe('module configuration', () => { const res1 = await supertest(app.getHttpServer()).post('/module-config?test=1') expect(useFactory).toHaveBeenCalledTimes(1) - expect(useFactory).toHaveBeenCalledWith(expect.objectContaining({ url: '/module-config?test=1', method: 'POST' })) + expect(useFactory).toHaveBeenCalledWith( + expect.objectContaining({ url: '/module-config?test=1', method: 'POST' }), + ) expect(res1.statusCode).toEqual(200) expect(res1.body).toEqual('forRootAsync') expect(routingInterceptor).toHaveBeenCalledTimes(1) @@ -92,13 +97,17 @@ describe('module configuration', () => { expect(res2.body).toEqual('forRootAsync') // because the request is different, the useFactory should be called again expect(useFactory).toHaveBeenCalledTimes(2) - expect(useFactory).toHaveBeenCalledWith(expect.objectContaining({ url: '/module-config?test=2', method: 'POST' })) + expect(useFactory).toHaveBeenCalledWith( + expect.objectContaining({ url: '/module-config?test=2', method: 'POST' }), + ) expect(routingInterceptor).toHaveBeenCalledTimes(2) expect(handler).toHaveBeenCalledTimes(2) }) it('should support context as an async function receiving ExecutionContext, isolated per request', async () => { - const context = vi.fn((ctx: ExecutionContext) => ({ source: ctx.switchToHttp().getRequest().url })) + const context = vi.fn((ctx: ExecutionContext) => ({ + source: ctx.switchToHttp().getRequest().url, + })) const moduleRef = await Test.createTestingModule({ controllers: [ImplController], diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts index 9566466e8a..a8fcc2b88b 100644 --- a/packages/nest/src/module.ts +++ b/packages/nest/src/module.ts @@ -1,13 +1,14 @@ import type { DynamicModule, ExecutionContext } from '@nestjs/common' +import { Module } from '@nestjs/common' import type { OpenAPIHandlerCodecCoreOptions } from '@orpc/openapi/standard' import type { DefaultInitialContext } from '@orpc/server' import type { StandardHandlerOptions } from '@orpc/server/standard' import type { Promisable, Value } from '@orpc/shared' import type { StandardLazyRequest } from '@standard-server/core' import type { ToEventStreamOptions } from '@standard-server/node' -import { Module } from '@nestjs/common' -import { ORPC_MODULE_CONFIG_SYMBOL } from './constants' -import { ImplementInterceptor } from './implement' + +import { ORPC_MODULE_CONFIG_SYMBOL } from './constants.ts' +import { ImplementInterceptor } from './implement.ts' /** * A standard lazy request extended with NestJS route parameters, @@ -22,29 +23,29 @@ export interface NestStandardLazyRequest extends StandardLazyRequest { params?: Record | undefined } -export type ORPCModuleConfig - = & OpenAPIHandlerCodecCoreOptions - & StandardHandlerOptions - & (object extends DefaultInitialContext - ? { context?: Value, [ctx: ExecutionContext]> | undefined } - : { context: Value, [ctx: ExecutionContext]> }) - & { - /** - * Customize how to convert NestJS `req` and `res` to {@link NestStandardLazyRequest}. - * You might need to define this if you are not using the express, fastify or node adapters. - */ - toNestStandardLazyRequest?: ((req: any, res: any) => NestStandardLazyRequest) | undefined +export type ORPCModuleConfig = OpenAPIHandlerCodecCoreOptions & + StandardHandlerOptions & + (object extends DefaultInitialContext + ? { context?: Value, [ctx: ExecutionContext]> | undefined } + : { context: Value, [ctx: ExecutionContext]> }) & { + /** + * Customize how to convert NestJS `req` and `res` to {@link NestStandardLazyRequest}. + * You might need to define this if you are not using the express, fastify or node adapters. + */ + toNestStandardLazyRequest?: ((req: any, res: any) => NestStandardLazyRequest) | undefined - /** - * Options for how to convert the Standard Response to a Nest Response (returning value), like event stream options, etc. - */ - toNestResponse?: { - /** - * Options for the event stream, like keep-alive settings, initial comment, etc. - */ - eventStream?: ToEventStreamOptions | undefined - } | undefined - } + /** + * Options for how to convert the Standard Response to a Nest Response (returning value), like event stream options, etc. + */ + toNestResponse?: + | { + /** + * Options for the event stream, like keep-alive settings, initial comment, etc. + */ + eventStream?: ToEventStreamOptions | undefined + } + | undefined + } /** * NestJS module that configures the `@orpc/nest` integration via `forRoot` @@ -54,6 +55,7 @@ export type ORPCModuleConfig * @see {@link https://orpc.dev/docs/integrations/nest#configuration | Implement oRPC contract with NestJS - Configuration} */ @Module({}) +// oxlint-disable-next-line typescript/no-extraneous-class export class ORPCModule { static forRoot(config: ORPCModuleConfig): DynamicModule { return { diff --git a/packages/nest/tsconfig.build.json b/packages/nest/tsconfig.build.json new file mode 100644 index 0000000000..d103e98370 --- /dev/null +++ b/packages/nest/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "lib": ["ES2022"], + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "types": ["node"] + } +} diff --git a/packages/nest/tsconfig.json b/packages/nest/tsconfig.json index e7b145abd5..c1ca0d454f 100644 --- a/packages/nest/tsconfig.json +++ b/packages/nest/tsconfig.json @@ -1,10 +1,11 @@ { - "extends": "../../tsconfig.lib.json", + "extends": "../../tsconfig.json", "compilerOptions": { "lib": ["ES2022"], "emitDecoratorMetadata": true, "experimentalDecorators": true, "types": ["node"] }, - "include": ["./package.json", "src", "tests"] + "include": ["src", "tests"], + "exclude": [] } diff --git a/packages/next/README.md b/packages/next/README.md index cd8ca558bd..b64a4cc83a 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/next/build.config.ts b/packages/next/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/next/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/next/package.json b/packages/next/package.json index 99135af9d2..6ef1014011 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,69 +1,64 @@ { "name": "@orpc/next", - "type": "module", "version": "2.0.0-beta.48", "description": "Next.js integration for oRPC: call typesafe procedures through Next.js Server Functions", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "next", + "nextjs", + "orpc", + "react", + "rpc", + "server-actions", + "server-functions", + "typesafe", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/next" }, - "keywords": [ - "orpc", - "nextjs", - "next", - "server-functions", - "server-actions", - "react", - "rpc", - "typescript", - "typesafe" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": [ - "./dist/extensions/actionable.mjs" + "./dist/extensions/actionable.js" ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./hooks": "./src/hooks/index.ts", + "./extensions/actionable": "./src/extensions/actionable.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./hooks": { - "types": "./dist/hooks/index.d.mts", - "import": "./dist/hooks/index.mjs", - "default": "./dist/hooks/index.mjs" + "types": "./dist/hooks/index.d.ts", + "import": "./dist/hooks/index.js", + "default": "./dist/hooks/index.js" }, "./extensions/actionable": { - "types": "./dist/extensions/actionable.d.mts", - "import": "./dist/extensions/actionable.mjs", - "default": "./dist/extensions/actionable.mjs" + "types": "./dist/extensions/actionable.d.ts", + "import": "./dist/extensions/actionable.js", + "default": "./dist/extensions/actionable.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts", - "./hooks": "./src/hooks/index.ts", - "./extensions/actionable": "./src/extensions/actionable.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "next": ">=16.2.7", - "react": ">=19.2.7" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", @@ -74,5 +69,9 @@ "devDependencies": { "next": "^16.4.4", "react": "^19.3.1" + }, + "peerDependencies": { + "next": ">=16.2.7", + "react": ">=19.2.7" } } diff --git a/packages/next/src/deferred-interceptors.test.ts b/packages/next/src/deferred-interceptors.test.ts index 004b261014..6d8653e5ab 100644 --- a/packages/next/src/deferred-interceptors.test.ts +++ b/packages/next/src/deferred-interceptors.test.ts @@ -1,61 +1,99 @@ import { intercept } from '@orpc/shared' -import { onErrorDeferred, onFinishDeferred, onStartDeferred, onSuccessDeferred } from './deferred-interceptors' + +import { + onErrorDeferred, + onFinishDeferred, + onStartDeferred, + onSuccessDeferred, +} from './deferred-interceptors.ts' describe('onStartDeferred/onSuccessDeferred/onErrorDeferred/onFinishDeferred', async () => { it('on success', async () => { const callback = vi.fn() - const output = await intercept([ - onFinishDeferred(callback), - onStartDeferred(callback), - onSuccessDeferred(callback), - onErrorDeferred(callback), - ], { - context: true, - }, async () => { - return 'test' - }) + const output = await intercept( + [ + onFinishDeferred(callback), + onStartDeferred(callback), + onSuccessDeferred(callback), + onErrorDeferred(callback), + ], + { + context: true, + }, + async () => { + return 'test' + }, + ) expect(output).toBe('test') expect(callback).toHaveBeenCalledTimes(0) - await new Promise(resolve => setTimeout(resolve, 6)) + await new Promise((resolve) => setTimeout(resolve, 6)) expect(callback).toHaveBeenCalledTimes(3) - expect(callback).toHaveBeenNthCalledWith(1, expect.objectContaining({ - context: true, - })) - expect(callback).toHaveBeenNthCalledWith(2, 'test', expect.objectContaining({ - context: true, - })) - expect(callback).toHaveBeenNthCalledWith(3, [null, 'test', true], expect.objectContaining({ - context: true, - })) + expect(callback).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + context: true, + }), + ) + expect(callback).toHaveBeenNthCalledWith( + 2, + 'test', + expect.objectContaining({ + context: true, + }), + ) + expect(callback).toHaveBeenNthCalledWith( + 3, + [null, 'test', true], + expect.objectContaining({ + context: true, + }), + ) }) it('on error', async () => { const callback = vi.fn() - await expect(intercept([ - onFinishDeferred(callback), - onStartDeferred(callback), - onSuccessDeferred(callback), - onErrorDeferred(callback), - ], { - context: true, - }, async () => { - throw new Error('test') - })).rejects.toThrow('test') + await expect( + intercept( + [ + onFinishDeferred(callback), + onStartDeferred(callback), + onSuccessDeferred(callback), + onErrorDeferred(callback), + ], + { + context: true, + }, + async () => { + throw new Error('test') + }, + ), + ).rejects.toThrow('test') expect(callback).toHaveBeenCalledTimes(0) - await new Promise(resolve => setTimeout(resolve, 6)) + await new Promise((resolve) => setTimeout(resolve, 6)) expect(callback).toHaveBeenCalledTimes(3) - expect(callback).toHaveBeenNthCalledWith(1, expect.objectContaining({ - context: true, - })) - expect(callback).toHaveBeenNthCalledWith(2, new Error('test'), expect.objectContaining({ - context: true, - })) - expect(callback).toHaveBeenNthCalledWith(3, [new Error('test'), undefined, false], expect.objectContaining({ - context: true, - })) + expect(callback).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + context: true, + }), + ) + expect(callback).toHaveBeenNthCalledWith( + 2, + new Error('test'), + expect.objectContaining({ + context: true, + }), + ) + expect(callback).toHaveBeenNthCalledWith( + 3, + [new Error('test'), undefined, false], + expect.objectContaining({ + context: true, + }), + ) }) }) diff --git a/packages/next/src/deferred-interceptors.ts b/packages/next/src/deferred-interceptors.ts index 8233d1576d..eae022bca1 100644 --- a/packages/next/src/deferred-interceptors.ts +++ b/packages/next/src/deferred-interceptors.ts @@ -6,11 +6,14 @@ import { onError, onFinish, onStart, onSuccess } from '@orpc/shared' * @see {@link https://orpc.dev/docs/integrations/next#hooks | Next.js Integration - Hooks} */ export const onStartDeferred: typeof onStart = (callback, ...rest) => { - return onStart((...args) => { - setTimeout(() => { - callback(...args) - }, 6) - }, ...rest) + return onStart( + (...args) => { + setTimeout(() => { + callback(...args) + }, 6) + }, + ...rest, + ) } /** @@ -19,11 +22,14 @@ export const onStartDeferred: typeof onStart = (callback, ...rest) => { * @see {@link https://orpc.dev/docs/integrations/next#hooks | Next.js Integration - Hooks} */ export const onSuccessDeferred: typeof onSuccess = (callback, ...rest) => { - return onSuccess((...args) => { - setTimeout(() => { - callback(...args) - }, 6) - }, ...rest) + return onSuccess( + (...args) => { + setTimeout(() => { + callback(...args) + }, 6) + }, + ...rest, + ) } /** @@ -32,11 +38,14 @@ export const onSuccessDeferred: typeof onSuccess = (callback, ...rest) => { * @see {@link https://orpc.dev/docs/integrations/next#hooks | Next.js Integration - Hooks} */ export const onErrorDeferred: typeof onError = (callback, ...rest) => { - return onError((...args) => { - setTimeout(() => { - callback(...args) - }, 6) - }, ...rest) + return onError( + (...args) => { + setTimeout(() => { + callback(...args) + }, 6) + }, + ...rest, + ) } /** @@ -45,9 +54,12 @@ export const onErrorDeferred: typeof onError = (callback, ...rest) => { * @see {@link https://orpc.dev/docs/integrations/next#hooks | Next.js Integration - Hooks} */ export const onFinishDeferred: typeof onFinish = (callback, ...rest) => { - return onFinish((...args) => { - setTimeout(() => { - callback(...args) - }, 6) - }, ...rest) + return onFinish( + (...args) => { + setTimeout(() => { + callback(...args) + }, 6) + }, + ...rest, + ) } diff --git a/packages/next/src/extensions/actionable.test-d.ts b/packages/next/src/extensions/actionable.test-d.ts index a42161b842..a63c4928f0 100644 --- a/packages/next/src/extensions/actionable.test-d.ts +++ b/packages/next/src/extensions/actionable.test-d.ts @@ -1,7 +1,8 @@ import type { DecoratedProcedure, Procedure } from '@orpc/server' -import type { ProcedureServerFunction } from '../server-function' import { z } from 'zod' -import './actionable' + +import type { ProcedureServerFunction } from '../server-function.ts' +import './actionable.ts' const errorMap = { BASE: { @@ -10,8 +11,8 @@ const errorMap = { }, } -const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +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< @@ -23,18 +24,14 @@ it('adds .actionable method to DecoratedProcedure', async () => { > expectTypeOf(procedure.actionable({ context: { auth: true } })).toEqualTypeOf< - & ProcedureServerFunction< - typeof schema1, - typeof schema2, - typeof errorMap - > - & Procedure< - { auth: boolean }, - { extra: string }, + ProcedureServerFunction & + Procedure< + { auth: boolean }, + { extra: string }, typeof schema1, typeof schema2, typeof errorMap - > + > >() // @ts-expect-error - invalid initial context diff --git a/packages/next/src/extensions/actionable.test.ts b/packages/next/src/extensions/actionable.test.ts index 473ce0a941..a6ba2ce27d 100644 --- a/packages/next/src/extensions/actionable.test.ts +++ b/packages/next/src/extensions/actionable.test.ts @@ -1,7 +1,8 @@ import { os, Procedure } from '@orpc/server' import { z } from 'zod' -import * as ServerFunctionModule from '../server-function' -import './actionable' + +import * as ServerFunctionModule from '../server-function.ts' +import './actionable.ts' const createServerFunctionSpy = vi.spyOn(ServerFunctionModule, 'createServerFunction') diff --git a/packages/next/src/extensions/actionable.ts b/packages/next/src/extensions/actionable.ts index 67786d877e..320be64807 100644 --- a/packages/next/src/extensions/actionable.ts +++ b/packages/next/src/extensions/actionable.ts @@ -1,8 +1,9 @@ import type { 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' +import type { MaybeOptionalOptions } from '@orpc/shared' + +import type { ProcedureServerFunction } from '../server-function.ts' +import { createServerFunction } from '../server-function.ts' declare module '@orpc/server' { interface DecoratedProcedure< @@ -14,16 +15,10 @@ declare module '@orpc/server' { > { actionable( ...rest: MaybeOptionalOptions< - ProcedureClientOptions< - TInitialContext, - TOutputSchema, - TErrorMap, - object - > + ProcedureClientOptions > - ): - & ProcedureServerFunction - & Procedure + ): ProcedureServerFunction & + Procedure } } diff --git a/packages/next/src/hooks/index.test.ts b/packages/next/src/hooks/index.test.ts index c7ba7ae9be..111fc52e0d 100644 --- a/packages/next/src/hooks/index.test.ts +++ b/packages/next/src/hooks/index.test.ts @@ -1,5 +1,5 @@ it('exports useServerFunction and useOptimisticServerFunction', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).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 index 8f6968c6c6..20f785009a 100644 --- a/packages/next/src/hooks/index.ts +++ b/packages/next/src/hooks/index.ts @@ -1,14 +1,14 @@ -export * from './optimistic-server-function' +export * from './optimistic-server-function.ts' export { /** * @deprecated Use `useOptimisticServerFunction` instead. */ useOptimisticServerFunction as useOptimisticServerAction, -} from './optimistic-server-function' -export * from './server-function' +} from './optimistic-server-function.ts' +export * from './server-function.ts' export { /** * @deprecated Use `useServerFunction` instead. */ useServerFunction as useServerAction, -} from './server-function' +} from './server-function.ts' diff --git a/packages/next/src/hooks/optimistic-server-function.test-d.ts b/packages/next/src/hooks/optimistic-server-function.test-d.ts index cbd339379a..b0f9066395 100644 --- a/packages/next/src/hooks/optimistic-server-function.test-d.ts +++ b/packages/next/src/hooks/optimistic-server-function.test-d.ts @@ -1,17 +1,16 @@ 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()) }) +import { createServerFunction } from '../server-function.ts' +import { useOptimisticServerFunction } from './optimistic-server-function.ts' + +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) } - }), + os.input(inputSchema.optional()).handler(async ({ input }) => { + return { output: Number(input) } + }), ) it('can infer optimistic state', () => { diff --git a/packages/next/src/hooks/optimistic-server-function.test.tsx b/packages/next/src/hooks/optimistic-server-function.test.tsx index b89770c425..2515a4a03c 100644 --- a/packages/next/src/hooks/optimistic-server-function.test.tsx +++ b/packages/next/src/hooks/optimistic-server-function.test.tsx @@ -2,10 +2,11 @@ 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}`) }) +import { createServerFunction } from '../server-function.ts' +import { useOptimisticServerFunction } from './optimistic-server-function.ts' + +export const inputSchema = z.object({ input: z.number().transform((n) => `${n}`) }) beforeEach(() => { vi.clearAllMocks() @@ -16,11 +17,7 @@ describe('useOptimisticServerFunction', () => { return { output: Number(input?.input ?? 0) } }) - const fn = createServerFunction( - os - .input(inputSchema) - .handler(handler), - ) + const fn = createServerFunction(os.input(inputSchema).handler(handler)) it.each(['success', 'error'])('on %s', async (scenario) => { if (scenario === 'error') { @@ -32,7 +29,7 @@ describe('useOptimisticServerFunction', () => { const state = useOptimisticServerFunction(fn, { optimisticPassthrough: outputs, optimisticReducer(state, input) { - return [...state, { output: Number(input?.input ?? 0) }] + return [...state, { output: input?.input ?? 0 }] }, }) @@ -40,7 +37,7 @@ describe('useOptimisticServerFunction', () => { }) act(() => { - result.current.state.execute({ input: 123 }) + void result.current.state.execute({ input: 123 }) }) expect(result.current.state.optimisticState).toEqual([{ output: 0 }, { output: 123 }]) @@ -50,7 +47,7 @@ describe('useOptimisticServerFunction', () => { expect(result.current.state.optimisticState).toEqual([{ output: 0 }]) act(() => { - result.current.setOutputs(prev => [...prev, { output: 123 }]) + 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 index dd86f5e4a7..7a30c69873 100644 --- a/packages/next/src/hooks/optimistic-server-function.ts +++ b/packages/next/src/hooks/optimistic-server-function.ts @@ -1,19 +1,25 @@ import type { AnyORPCErrorJSON } from '@orpc/client' -import type { ServerFunction, ServerFunctionError } from '../server-function' -import type { UseServerFunctionOptions, UseServerFunctionResult } from './server-function' import { onStart, toArray } from '@orpc/shared' import { useMemo, useOptimistic } from 'react' -import { useServerFunction } from './server-function' -export interface UseOptimisticServerFunctionOptions extends - UseServerFunctionOptions { +import type { ServerFunction, ServerFunctionError } from '../server-function.ts' +import type { UseServerFunctionOptions, UseServerFunctionResult } from './server-function.ts' +import { useServerFunction } from './server-function.ts' + +export interface UseOptimisticServerFunctionOptions< + TInput, + TOutput, + TError, + TOptimisticState, +> extends UseServerFunctionOptions { optimisticPassthrough: TOptimisticState optimisticReducer: (state: TOptimisticState, input: TInput) => TOptimisticState } -export type UseOptimisticServerFunctionResult = UseServerFunctionResult & { - optimisticState: TOptimisticState -} +export type UseOptimisticServerFunctionResult = + UseServerFunctionResult & { + optimisticState: TOptimisticState + } /** * Like `useServerFunction`, but additionally applies optimistic state updates @@ -21,11 +27,29 @@ export type UseOptimisticServerFunctionResult( +export function useOptimisticServerFunction< + TInput, + TOutput, + TError extends AnyORPCErrorJSON, + TOptimisticState, +>( fn: ServerFunction, - options: UseOptimisticServerFunctionOptions, TOptimisticState>, -): UseOptimisticServerFunctionResult, TOptimisticState> { - const [optimisticState, addOptimistic] = useOptimistic(options.optimisticPassthrough, options.optimisticReducer) + options: UseOptimisticServerFunctionOptions< + TInput, + TOutput, + ServerFunctionError, + TOptimisticState + >, +): UseOptimisticServerFunctionResult< + TInput, + TOutput, + ServerFunctionError, + TOptimisticState +> { + const [optimisticState, addOptimistic] = useOptimistic( + options.optimisticPassthrough, + options.optimisticReducer, + ) const state = useServerFunction(fn, { ...options, diff --git a/packages/next/src/hooks/server-function.test-d.ts b/packages/next/src/hooks/server-function.test-d.ts index 51ba1dcac0..3e2d81e18d 100644 --- a/packages/next/src/hooks/server-function.test-d.ts +++ b/packages/next/src/hooks/server-function.test-d.ts @@ -1,12 +1,13 @@ 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}`) }) +import { createServerFunction } from '../server-function.ts' +import { useServerFunction } from './server-function.ts' -export const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) +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: { @@ -29,25 +30,28 @@ describe('useServerFunction', () => { const state = useServerFunction(fn) it('infer correct input', () => { - state.execute({ input: 123 }) - state.execute(undefined) - state.execute() + void state.execute({ input: 123 }) + void state.execute(undefined) + void state.execute() // @ts-expect-error --- input is invalid - state.execute({ input: 'invalid' }) + void state.execute({ input: 'invalid' }) expectTypeOf(state.input).toEqualTypeOf() }) it('require non-undefindable input ', () => { - const action = os.input(z.string()).handler(() => 123).actionable() + const action = os + .input(z.string()) + .handler(() => 123) + .actionable() const state = useServerFunction(action) - state.execute('123') + void state.execute('123') // @ts-expect-error --- missing input - state.execute() + void state.execute() // @ts-expect-error --- invalid input - state.execute(123) + void state.execute(123) if (!state.isIdle || state.status !== 'idle') { expectTypeOf(state.input).toEqualTypeOf() @@ -63,7 +67,9 @@ describe('useServerFunction', () => { const [error, data, definedError] = await safe(next()) if (definedError) { - expectTypeOf(error).toEqualTypeOf | ORPCError<'OVERRIDE', unknown>>() + expectTypeOf(error).toEqualTypeOf< + ORPCError<'BASE', { output: string }> | ORPCError<'OVERRIDE', unknown> + >() } if (!error) { @@ -77,34 +83,41 @@ describe('useServerFunction', () => { ], }) - state.execute({ input: 123 }, { - interceptors: [ - async ({ input, next }) => { - expectTypeOf(input).toEqualTypeOf<{ input: number } | undefined>() + void state.execute( + { input: 123 }, + { + interceptors: [ + async ({ input, next }) => { + expectTypeOf(input).toEqualTypeOf<{ input: number } | undefined>() - const [error, data, definedError] = await safe(next()) + const [error, data, definedError] = await safe(next()) - if (definedError) { - expectTypeOf(error).toEqualTypeOf | ORPCError<'OVERRIDE', unknown>>() - } + if (definedError) { + expectTypeOf(error).toEqualTypeOf< + ORPCError<'BASE', { output: string }> | ORPCError<'OVERRIDE', unknown> + >() + } - if (!error) { - expectTypeOf(data).toEqualTypeOf<{ output: string }>() + if (!error) { + expectTypeOf(data).toEqualTypeOf<{ output: string }>() - return data - } + return data + } - return next() - }, - ], - }) + return next() + }, + ], + }, + ) }) it('output & error', async () => { const [error, data, definedError] = await state.execute({ input: 123 }) if (definedError) { - expectTypeOf(error).toEqualTypeOf | ORPCError<'OVERRIDE', unknown>>() + expectTypeOf(error).toEqualTypeOf< + ORPCError<'BASE', { output: string }> | ORPCError<'OVERRIDE', unknown> + >() } if (!error) { diff --git a/packages/next/src/hooks/server-function.test.tsx b/packages/next/src/hooks/server-function.test.tsx index 506f131931..e4ed50d004 100644 --- a/packages/next/src/hooks/server-function.test.tsx +++ b/packages/next/src/hooks/server-function.test.tsx @@ -1,12 +1,13 @@ 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}`) }) +import { createServerFunction } from '../server-function.ts' +import { useServerFunction } from './server-function.ts' -export const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) +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: { @@ -25,11 +26,7 @@ describe('useServerFunction', () => { }) const fn = createServerFunction( - os - .input(inputSchema.optional()) - .errors(baseErrorMap) - .output(outputSchema) - .handler(handler), + os.input(inputSchema.optional()).errors(baseErrorMap).output(outputSchema).handler(handler), ) it('on success', async () => { @@ -45,7 +42,7 @@ describe('useServerFunction', () => { expect(result.current.error).toBe(null) act(() => { - result.current.execute({ input: 123 }) + void result.current.execute({ input: 123 }) }) expect(result.current.status).toBe('pending') @@ -94,7 +91,7 @@ describe('useServerFunction', () => { act(() => { // @ts-expect-error --- invalid input - result.current.execute({ input: 'invalid' }) + void result.current.execute({ input: 'invalid' }) }) expect(result.current.status).toBe('pending') @@ -130,9 +127,11 @@ describe('useServerFunction', () => { }) it('on action calling error', async () => { - const { result } = renderHook(() => useServerFunction(() => { - throw new Error('failed to call') - })) + const { result } = renderHook(() => + useServerFunction(() => { + throw new Error('failed to call') + }), + ) expect(result.current.status).toBe('idle') expect(result.current.isIdle).toBe(true) @@ -144,7 +143,7 @@ describe('useServerFunction', () => { expect(result.current.error).toBe(null) act(() => { - result.current.execute({ input: 123 }) + void result.current.execute({ input: 123 }) }) expect(result.current.status).toBe('pending') @@ -184,21 +183,22 @@ describe('useServerFunction', () => { const interceptor = vi.fn(({ next }) => next()) const executeInterceptor = vi.fn(({ next }) => next()) - const { result } = renderHook(() => useServerFunction(fn, { - interceptors: [ - interceptor, - ], - })) + const { result } = renderHook(() => + useServerFunction(fn, { + interceptors: [interceptor], + }), + ) expect(interceptor).toHaveBeenCalledTimes(0) expect(executeInterceptor).toHaveBeenCalledTimes(0) act(() => { - result.current.execute({ input: 123 }, { - interceptors: [ - executeInterceptor, - ], - }) + void result.current.execute( + { input: 123 }, + { + interceptors: [executeInterceptor], + }, + ) }) expect(interceptor).toHaveBeenCalledTimes(1) @@ -269,7 +269,7 @@ describe('useServerFunction', () => { expect(result.current.status).toBe('idle') handler.mockImplementationOnce(async () => { - await new Promise(resolve => setTimeout(resolve, 20)) + await new Promise((resolve) => setTimeout(resolve, 20)) return { output: 123 } }) @@ -286,7 +286,7 @@ describe('useServerFunction', () => { expect(result.current.error).toBeNull() handler.mockImplementationOnce(async () => { - await new Promise(resolve => setTimeout(resolve, 40)) + await new Promise((resolve) => setTimeout(resolve, 40)) return { output: 456 } }) @@ -329,7 +329,7 @@ describe('useServerFunction', () => { expect(result.current.status).toBe('idle') handler.mockImplementationOnce(async () => { - await new Promise(resolve => setTimeout(resolve, 20)) + await new Promise((resolve) => setTimeout(resolve, 20)) return { output: 123 } }) diff --git a/packages/next/src/hooks/server-function.ts b/packages/next/src/hooks/server-function.ts index 373eb5f261..2e9699588e 100644 --- a/packages/next/src/hooks/server-function.ts +++ b/packages/next/src/hooks/server-function.ts @@ -1,28 +1,36 @@ 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 type { Interceptor, PromiseWithError } from '@orpc/shared' import { intercept, toArray } from '@orpc/shared' import { useCallback, useMemo, useRef, useState, useTransition } from 'react' +import type { ServerFunction, ServerFunctionError } from '../server-function.ts' + export interface UseServerFunctionOptions { interceptors?: Interceptor<{ input: TInput }, PromiseWithError>[] | undefined } -export interface UseServerFunctionExecuteOptions extends Pick, 'interceptors'> { -} +export interface UseServerFunctionExecuteOptions extends Pick< + UseServerFunctionOptions, + 'interceptors' +> {} -export type UseServerFunctionExecuteRest - = undefined extends TInput - ? [input?: TInput, options?: UseServerFunctionExecuteOptions] - : [input: TInput, options?: UseServerFunctionExecuteOptions] +export type UseServerFunctionExecuteRest = undefined extends TInput + ? [input?: TInput, options?: UseServerFunctionExecuteOptions] + : [input: TInput, options?: UseServerFunctionExecuteOptions] export interface UseServerFunctionResultBase { reset: () => void - execute: (...rest: UseServerFunctionExecuteRest) => Promise> + execute: ( + ...rest: UseServerFunctionExecuteRest + ) => Promise> } -export interface UseServerFunctionIdleResult extends UseServerFunctionResultBase { +export interface UseServerFunctionIdleResult< + TInput, + TOutput, + TError, +> extends UseServerFunctionResultBase { input: undefined data: undefined error: null @@ -34,7 +42,11 @@ export interface UseServerFunctionIdleResult extends Us executedAt: undefined } -export interface UseServerFunctionPendingResult extends UseServerFunctionResultBase { +export interface UseServerFunctionPendingResult< + TInput, + TOutput, + TError, +> extends UseServerFunctionResultBase { input: TInput data: undefined error: null @@ -46,7 +58,11 @@ export interface UseServerFunctionPendingResult extends executedAt: Date } -export interface UseServerFunctionSuccessResult extends UseServerFunctionResultBase { +export interface UseServerFunctionSuccessResult< + TInput, + TOutput, + TError, +> extends UseServerFunctionResultBase { input: TInput data: TOutput error: null @@ -58,7 +74,11 @@ export interface UseServerFunctionSuccessResult extends executedAt: Date } -export interface UseServerFunctionErrorResult extends UseServerFunctionResultBase { +export interface UseServerFunctionErrorResult< + TInput, + TOutput, + TError, +> extends UseServerFunctionResultBase { input: TInput data: undefined error: TError @@ -70,11 +90,11 @@ export interface UseServerFunctionErrorResult extends U executedAt: Date } -export type UseServerFunctionResult - = | UseServerFunctionIdleResult - | UseServerFunctionSuccessResult - | UseServerFunctionErrorResult - | UseServerFunctionPendingResult +export type UseServerFunctionResult = + | UseServerFunctionIdleResult + | UseServerFunctionSuccessResult + | UseServerFunctionErrorResult + | UseServerFunctionPendingResult const INITIAL_STATE = { data: undefined, @@ -109,12 +129,17 @@ export function useServerFunction, options: UseServerFunctionOptions> = {}, ): UseServerFunctionResult> { - const [state, setState] = useState> - | UseServerFunctionSuccessResult> - | UseServerFunctionErrorResult>, - keyof UseServerFunctionResultBase> | 'executedAt' | 'input' - >>(INITIAL_STATE) + const [state, setState] = + useState< + Omit< + | UseServerFunctionIdleResult> + | UseServerFunctionSuccessResult> + | UseServerFunctionErrorResult>, + | keyof UseServerFunctionResultBase> + | 'executedAt' + | 'input' + > + >(INITIAL_STATE) const executedAtRef = useRef(undefined) const [input, setInput] = useState(undefined) @@ -129,50 +154,61 @@ export function useServerFunction> = {}) => { - const executedAt = new Date() - executedAtRef.current = executedAt - - setInput(input) - - return new Promise((resolve) => { - startTransition(async () => { - const result = await safe(intercept( - [...toArray(interceptorsRef.current), ...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) + const execute = useCallback( + async ( + input: TInput, + executeOptions: UseServerFunctionExecuteOptions< + TInput, + TOutput, + ServerFunctionError + > = {}, + ) => { + const executedAt = new Date() + executedAtRef.current = executedAt + + setInput(input) + + return new Promise((resolve) => { + startTransition(async () => { + const result = await safe( + intercept( + [...toArray(interceptorsRef.current), ...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]) + }, + [fn], + ) const result = useMemo(() => { - const currentState = isPending && executedAtRef.current !== undefined - ? PENDING_STATE - : state + const currentState = isPending && executedAtRef.current !== undefined ? PENDING_STATE : state return { ...currentState, diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index b6b44800d5..a482eb5418 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -1,5 +1,5 @@ it('exports server function and server functionable, deferred interceptors, form helpers', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ createServerFunction: expect.any(Function), createServerFunctionable: expect.any(Function), createServerFormFunction: expect.any(Function), diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index c580258d01..8a79fb51a2 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,16 +1,13 @@ -export * from './deferred-interceptors' -export * from './server-form-function' +export * from './deferred-interceptors.ts' +export * from './server-form-function.ts' export { /** * @deprecated Use `createServerFormFunction` instead. */ createServerFormFunction as createFormAction, -} from './server-form-function' -export * from './server-form-functionable' -export * from './server-function' -export * from './server-functionable' +} from './server-form-function.ts' +export * from './server-form-functionable.ts' +export * from './server-function.ts' +export * from './server-functionable.ts' -export { - getIssueMessage, - parseFormData, -} from '@orpc/openapi/helpers' +export { getIssueMessage, parseFormData } from '@orpc/openapi/helpers' diff --git a/packages/next/src/server-form-function.test.ts b/packages/next/src/server-form-function.test.ts index 96a3fe7133..263ebfa7a3 100644 --- a/packages/next/src/server-form-function.test.ts +++ b/packages/next/src/server-form-function.test.ts @@ -1,5 +1,6 @@ import * as ServerModule from '@orpc/server' -import { createServerFormFunction } from './server-form-function' + +import { createServerFormFunction } from './server-form-function.ts' const createProcedureClientSpy = vi.spyOn(ServerModule, 'createProcedureClient') const { os, type } = ServerModule @@ -9,7 +10,10 @@ beforeEach(() => { }) describe('createServerFormFunction', () => { - const procedure = os.input(type()).output(type()).handler(() => 'output') + 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) diff --git a/packages/next/src/server-form-function.ts b/packages/next/src/server-form-function.ts index 5c5ee75e6e..dbc463b2bb 100644 --- a/packages/next/src/server-form-function.ts +++ b/packages/next/src/server-form-function.ts @@ -1,7 +1,14 @@ -import type { AnySchema, Context, ErrorMap, Lazyable, Procedure, ProcedureClientOptions } from '@orpc/server' -import type { MaybeOptionalOptions } from '@orpc/shared' import { parseFormData } from '@orpc/openapi/helpers' +import type { + AnySchema, + Context, + ErrorMap, + Lazyable, + Procedure, + ProcedureClientOptions, +} from '@orpc/server' import { createProcedureClient } from '@orpc/server' +import type { MaybeOptionalOptions } from '@orpc/shared' import { resolveMaybeOptionalOptions } from '@orpc/shared' export interface ServerFormFunction { @@ -14,20 +21,9 @@ export function createServerFormFunction< TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, >( - procedure: Lazyable>, + procedure: Lazyable>, ...rest: MaybeOptionalOptions< - ProcedureClientOptions< - TInitialContext, - TOutputSchema, - TErrorMap, - object - > + ProcedureClientOptions > ): ServerFormFunction { const options = resolveMaybeOptionalOptions(rest) diff --git a/packages/next/src/server-form-functionable.test-d.ts b/packages/next/src/server-form-functionable.test-d.ts index b52783f94e..3a3c74909e 100644 --- a/packages/next/src/server-form-functionable.test-d.ts +++ b/packages/next/src/server-form-functionable.test-d.ts @@ -1,8 +1,9 @@ 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' + +import type { ServerFormFunction } from './server-form-function.ts' +import { createServerFormFunctionable } from './server-form-functionable.ts' const errorMap = { BASE: { @@ -11,8 +12,8 @@ const errorMap = { }, } -const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +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({ @@ -22,11 +23,16 @@ describe('createServerFormFunctionable', () => { 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 })), + 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> + ServerFormFunction & + Procedure<{ auth: boolean }, object, typeof schema1, typeof schema2, typeof errorMap> >() }) diff --git a/packages/next/src/server-form-functionable.test.ts b/packages/next/src/server-form-functionable.test.ts index 96f906302b..5aeb059e91 100644 --- a/packages/next/src/server-form-functionable.test.ts +++ b/packages/next/src/server-form-functionable.test.ts @@ -1,6 +1,7 @@ import * as ServerModule from '@orpc/server' -import * as ServerFormFunctionModule from './server-form-function' -import { createServerFormFunctionable } from './server-form-functionable' + +import * as ServerFormFunctionModule from './server-form-function.ts' +import { createServerFormFunctionable } from './server-form-functionable.ts' const createServerFormFunctionSpy = vi.spyOn(ServerFormFunctionModule, 'createServerFormFunction') const { os, type } = ServerModule @@ -10,7 +11,10 @@ beforeEach(() => { }) describe('createServerFormFunctionable', () => { - const procedure = os.input(type()).output(type()).handler(() => 'output') + 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 diff --git a/packages/next/src/server-form-functionable.ts b/packages/next/src/server-form-functionable.ts index 20280e8667..20a6a86f33 100644 --- a/packages/next/src/server-form-functionable.ts +++ b/packages/next/src/server-form-functionable.ts @@ -1,8 +1,16 @@ -import type { AnySchema, Context, ErrorMap, Procedure, ProcedureClientOptions, Schema } from '@orpc/server' +import type { + 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' + +import type { ServerFormFunction } from './server-form-function.ts' +import { createServerFormFunction } from './server-form-function.ts' export interface ServerFormFunctionable { < @@ -11,10 +19,9 @@ export interface ServerFormFunctionable { TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, >( - procedure: Procedure - ): - & ServerFormFunction - & Procedure + procedure: Procedure, + ): ServerFormFunction & + Procedure } /** @@ -26,12 +33,7 @@ export interface ServerFormFunctionable { */ export function createServerFormFunctionable( ...rest: MaybeOptionalOptions< - ProcedureClientOptions< - TInitialContext, - Schema, - ErrorMap, - object - > + ProcedureClientOptions, ErrorMap, object> > ): ServerFormFunctionable { const options = resolveMaybeOptionalOptions(rest) @@ -42,20 +44,10 @@ export function createServerFormFunctionable( - procedure: Procedure< - TInitialContext, - TInjectedContext, - TInputSchema, - TOutputSchema, - TErrorMap - >, + procedure: Procedure, ) => { - const functionable = createServerFormFunction( - procedure, - options, - ) as - & ServerFormFunction - & Procedure + const functionable = createServerFormFunction(procedure, options) as ServerFormFunction & + Procedure functionable['~orpc'] = procedure['~orpc'] diff --git a/packages/next/src/server-function.test.tsx b/packages/next/src/server-function.action-state.test.tsx similarity index 82% rename from packages/next/src/server-function.test.tsx rename to packages/next/src/server-function.action-state.test.tsx index 92cd3fcd4a..443edbe505 100644 --- a/packages/next/src/server-function.test.tsx +++ b/packages/next/src/server-function.action-state.test.tsx @@ -2,13 +2,12 @@ import { os } from '@orpc/server' import { act, renderHook } from '@testing-library/react' import { startTransition, useActionState } from 'react' import { z } from 'zod' -import { createServerFunction } from './server-function' + +import { createServerFunction } from './server-function.ts' describe('createServerFunction with useActionState', () => { const fn = createServerFunction( - os - .input(z.object({ name: z.string().min(3) })) - .handler(({ input }) => `Hello ${input.name}!`), + os.input(z.object({ name: z.string().min(3) })).handler(({ input }) => `Hello ${input.name}!`), ) it('works as a useActionState action', async () => { diff --git a/packages/next/src/server-function.test-d.ts b/packages/next/src/server-function.test-d.ts index 265437dbc2..a2ebe834b8 100644 --- a/packages/next/src/server-function.test-d.ts +++ b/packages/next/src/server-function.test-d.ts @@ -2,7 +2,8 @@ import type { ORPCErrorCode } from '@orpc/server' import { os } from '@orpc/server' import { useActionState } from 'react' import { z } from 'zod' -import { createServerFunction } from './server-function' + +import { createServerFunction } from './server-function.ts' const errorMap = { BASE: { @@ -11,8 +12,8 @@ const errorMap = { }, } -const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +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 @@ -32,9 +33,9 @@ describe('createServerFunction', () => { const fn = createServerFunction(procedure, { context: () => ({ auth: true }) }) // @ts-expect-error missing input - fn() + void fn() // @ts-expect-error invalid input - fn('invalid') + void fn('invalid') const [error, data] = await fn({ schema1: 123 }) if (error) { @@ -42,13 +43,11 @@ describe('createServerFunction', () => { if (error.code === 'BASE') { expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() } - } - else { + } else { expectTypeOf(error.code).toEqualTypeOf() expectTypeOf(error.data).toEqualTypeOf() } - } - else { + } else { expectTypeOf(data).toEqualTypeOf<{ schema2: string }>() } }) diff --git a/packages/next/src/server-function.test.ts b/packages/next/src/server-function.test.ts index e1576faa5c..a3578aee25 100644 --- a/packages/next/src/server-function.test.ts +++ b/packages/next/src/server-function.test.ts @@ -1,7 +1,8 @@ import { ORPCError } from '@orpc/server' import * as ServerModule from '@orpc/server' -import * as next from 'next/navigation' -import { createServerFunction } from './server-function' +import * as next from 'next/navigation.js' + +import { createServerFunction } from './server-function.ts' const createProcedureClientSpy = vi.spyOn(ServerModule, 'createProcedureClient') const { os, type } = ServerModule @@ -11,7 +12,10 @@ beforeEach(() => { }) describe('createServerFunction', () => { - const procedure = os.input(type()).output(type()).handler(() => 'output') + 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' }) @@ -31,9 +35,7 @@ describe('createServerFunction', () => { message: 'Invalid input', data: { field: 'input' }, }) - const client = vi.fn() - .mockRejectedValueOnce(error) - .mockRejectedValueOnce(new Error('TEST')) + const client = vi.fn().mockRejectedValueOnce(error).mockRejectedValueOnce(new Error('TEST')) createProcedureClientSpy.mockReturnValueOnce(client) const args = [procedure, { context: { context: true } }] as const @@ -42,7 +44,10 @@ describe('createServerFunction', () => { 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]) + await expect(serverFn({ input: 'ping' })).resolves.toEqual([ + new ORPCError('INTERNAL_SERVER_ERROR').toJSON(), + undefined, + ]) }) it('passes form data as-is when called with a single argument', async () => { @@ -64,13 +69,12 @@ describe('createServerFunction', () => { [() => next.unauthorized()], [() => next.notFound()], ])('rethrows special Next.js errors %s', async (createError) => { - (process as any).env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS = true + ;(process as any).env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS = true let error try { createError() - } - catch (e) { + } catch (e) { error = e } diff --git a/packages/next/src/server-function.ts b/packages/next/src/server-function.ts index d7adef8131..7af379a48c 100644 --- a/packages/next/src/server-function.ts +++ b/packages/next/src/server-function.ts @@ -1,25 +1,44 @@ -import type { AnyORPCErrorJSON, AnySchema, Context, ErrorMap, InferSchemaInput, InferSchemaOutput, Lazyable, ORPCError, ORPCErrorCode, ORPCErrorFromErrorMap, ORPCErrorJSON, Procedure, ProcedureClientOptions, ThrowableError } from '@orpc/server' -import type { MaybeOptionalOptions } from '@orpc/shared' import { parseFormData } from '@orpc/openapi/helpers' +import type { + AnyORPCErrorJSON, + AnySchema, + Context, + ErrorMap, + InferSchemaInput, + InferSchemaOutput, + Lazyable, + ORPCError, + ORPCErrorCode, + ORPCErrorFromErrorMap, + ORPCErrorJSON, + Procedure, + ProcedureClientOptions, + ThrowableError, +} from '@orpc/server' import { createProcedureClient, toORPCError } from '@orpc/server' +import type { MaybeOptionalOptions } from '@orpc/shared' import { resolveMaybeOptionalOptions } from '@orpc/shared' -import { unstable_rethrow } from 'next/navigation' +import { unstable_rethrow } from 'next/navigation.js' -export type ServerFunctionORPCErrorJSON - = T extends ORPCError +export type ServerFunctionORPCErrorJSON = + T extends ORPCError ? ORPCErrorJSON & { defined: true } : ORPCErrorJSON & { defined: false } -export type ServerFunctionError - = T extends ORPCErrorJSON & { defined: true } - ? ORPCError - : ThrowableError +export type ServerFunctionError = T extends ORPCErrorJSON< + infer U, + infer V +> & { defined: true } + ? ORPCError + : ThrowableError -export type ServerFunctionRest - = | [input: TInput] - | (undefined extends TInput ? [input?: TInput] : [input: TInput]) +export type ServerFunctionRest = + | [input: TInput] + | (undefined extends TInput ? [input?: TInput] : [input: TInput]) -export type ServerFunctionResult = [error: null, data: TOutput] | [error: TError, data: undefined] +export type ServerFunctionResult = + | [error: null, data: TOutput] + | [error: TError, data: undefined] export interface ServerFunction { (...rest: ServerFunctionRest): Promise> @@ -60,20 +79,9 @@ export function createServerFunction< TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, >( - procedure: Lazyable>, + procedure: Lazyable>, ...rest: MaybeOptionalOptions< - ProcedureClientOptions< - TInitialContext, - TOutputSchema, - TErrorMap, - object - > + ProcedureClientOptions > ): ProcedureServerFunction { const options = resolveMaybeOptionalOptions(rest) @@ -88,13 +96,14 @@ export function createServerFunction< try { return [null, await client(input as any)] - } - catch (error) { + } catch (error) { // https://nextjs.org/docs/app/api-reference/functions/unstable_rethrow unstable_rethrow(error) return [ - toORPCError(error).toJSON() as ServerFunctionORPCErrorJSON | ThrowableError>, + toORPCError(error).toJSON() as ServerFunctionORPCErrorJSON< + ORPCErrorFromErrorMap | ThrowableError + >, undefined, ] } diff --git a/packages/next/src/server-functionable.test-d.ts b/packages/next/src/server-functionable.test-d.ts index 7d57e5163f..3bdf62f865 100644 --- a/packages/next/src/server-functionable.test-d.ts +++ b/packages/next/src/server-functionable.test-d.ts @@ -1,8 +1,9 @@ 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' + +import type { ProcedureServerFunction } from './server-function.ts' +import { createServerFunctionable } from './server-functionable.ts' const errorMap = { BASE: { @@ -11,8 +12,8 @@ const errorMap = { }, } -const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +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({ @@ -22,11 +23,16 @@ describe('createServerFunctionable', () => { 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 })), + 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> + ProcedureServerFunction & + Procedure<{ auth: boolean }, object, typeof schema1, typeof schema2, typeof errorMap> >() }) diff --git a/packages/next/src/server-functionable.test.ts b/packages/next/src/server-functionable.test.ts index d103375762..2fef0cb29b 100644 --- a/packages/next/src/server-functionable.test.ts +++ b/packages/next/src/server-functionable.test.ts @@ -1,6 +1,7 @@ import * as ServerModule from '@orpc/server' -import * as ServerFunctionModule from './server-function' -import { createServerFunctionable } from './server-functionable' + +import * as ServerFunctionModule from './server-function.ts' +import { createServerFunctionable } from './server-functionable.ts' const createServerFunctionSpy = vi.spyOn(ServerFunctionModule, 'createServerFunction') const { os, type } = ServerModule @@ -10,7 +11,10 @@ beforeEach(() => { }) describe('createServerFunctionable', () => { - const procedure = os.input(type()).output(type()).handler(() => 'output') + 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 diff --git a/packages/next/src/server-functionable.ts b/packages/next/src/server-functionable.ts index 0143840199..e4cf045623 100644 --- a/packages/next/src/server-functionable.ts +++ b/packages/next/src/server-functionable.ts @@ -1,8 +1,16 @@ -import type { AnySchema, Context, ErrorMap, Procedure, ProcedureClientOptions, Schema } from '@orpc/server' +import type { + 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' + +import type { ProcedureServerFunction } from './server-function.ts' +import { createServerFunction } from './server-function.ts' export interface ServerFunctionable { < @@ -11,10 +19,9 @@ export interface ServerFunctionable { TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, >( - procedure: Procedure - ): - & ProcedureServerFunction - & Procedure + procedure: Procedure, + ): ProcedureServerFunction & + Procedure } /** @@ -26,12 +33,7 @@ export interface ServerFunctionable { */ export function createServerFunctionable( ...rest: MaybeOptionalOptions< - ProcedureClientOptions< - TInitialContext, - Schema, - ErrorMap, - object - > + ProcedureClientOptions, ErrorMap, object> > ): ServerFunctionable { const options = resolveMaybeOptionalOptions(rest) @@ -44,12 +46,12 @@ export function createServerFunctionable( procedure: Procedure, ) => { - const functionable = createServerFunction( - procedure, - options, - ) as - & ProcedureServerFunction - & Procedure + const functionable = createServerFunction(procedure, options) as ProcedureServerFunction< + TInputSchema, + TOutputSchema, + TErrorMap + > & + Procedure functionable['~orpc'] = procedure['~orpc'] diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index 18a5decb88..9979a0715c 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -1,18 +1,3 @@ { - "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__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/node/README.md b/packages/node/README.md index 09c1dcbde3..fca6e096ab 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/node/build.config.ts b/packages/node/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/node/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/node/package.json b/packages/node/package.json index 1a9ea47be0..e2052fa4c3 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,49 +1,48 @@ { "name": "@orpc/node", - "type": "module", "version": "2.0.0-beta.48", "description": "Node.js plugins for oRPC: static file serving, temporary file uploads, and batch response compression", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "compression", + "file-upload", + "node", + "nodejs", + "orpc", + "static-files", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/node" }, - "keywords": [ - "orpc", - "nodejs", - "node", - "static-files", - "file-upload", - "compression", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" + "prepack": "tsc" }, "dependencies": { "@orpc/server": "workspace:*", diff --git a/packages/node/src/batch-response-compression-handler-plugin.test.ts b/packages/node/src/batch-response-compression-handler-plugin.test.ts index 69a8b43bc1..c71b6e84c7 100644 --- a/packages/node/src/batch-response-compression-handler-plugin.test.ts +++ b/packages/node/src/batch-response-compression-handler-plugin.test.ts @@ -1,11 +1,17 @@ -import type { AnyRouter } from '@orpc/server' -import type { BatchResponseCompressionHandlerPluginOptions } from './batch-response-compression-handler-plugin' import { Buffer } from 'node:buffer' + +import type { AnyRouter } from '@orpc/server' import { os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' -import { BATCH_CONTENT_TYPE, BatchHandlerPlugin, ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' +import { + BATCH_CONTENT_TYPE, + BatchHandlerPlugin, + ResponseCompressionHandlerPlugin, +} from '@orpc/server/plugins' import { promiseWithResolvers } from '@orpc/shared' -import { BatchResponseCompressionHandlerPlugin } from './batch-response-compression-handler-plugin' + +import type { BatchResponseCompressionHandlerPluginOptions } from './batch-response-compression-handler-plugin.ts' +import { BatchResponseCompressionHandlerPlugin } from './batch-response-compression-handler-plugin.ts' // Long enough that a compressed body is unmistakably smaller than the identity one const largeValue = 'batched-payload-'.repeat(64) @@ -22,12 +28,14 @@ function createBatchRequest( return new Request('https://example.com/__batch__', { method: 'POST', headers: { ...headers, 'content-type': 'application/json', 'orpc-batch': '1' }, - body: JSON.stringify(urls.map((url, index) => ({ - kind: 'request', - id: String(index), - json: { method: 'POST', url, headers: {}, body: undefined }, - binary: undefined, - }))), + body: JSON.stringify( + urls.map((url, index) => ({ + kind: 'request', + id: String(index), + json: { method: 'POST', url, headers: {}, body: undefined }, + binary: undefined, + })), + ), }) } @@ -49,15 +57,14 @@ describe('batchResponseCompressionHandlerPlugin', () => { handlerRouter: AnyRouter = router as AnyRouter, ) { return new RPCHandler(handlerRouter, { - plugins: [ - new BatchHandlerPlugin(), - new BatchResponseCompressionHandlerPlugin(options), - ], + plugins: [new BatchHandlerPlugin(), new BatchResponseCompressionHandlerPlugin(options)], }) } it('compresses batch responses that stream', async () => { - const { matched, response } = await createHandler().handle(createBatchRequest(['/events', '/ping'])) + const { matched, response } = await createHandler().handle( + createBatchRequest(['/events', '/ping']), + ) expect(matched).toBe(true) expect(response!.status).toBe(207) @@ -79,12 +86,15 @@ describe('batchResponseCompressionHandlerPlugin', () => { it('delivers each message as soon as it is produced, without waiting for the stream to end', async () => { const slow = promiseWithResolvers() - const handler = createHandler({}, { - stream: os.handler(async function* () { - yield largeValue - yield await slow.promise - }), - }) + const handler = createHandler( + {}, + { + stream: os.handler(async function* () { + yield largeValue + yield await slow.promise + }), + }, + ) const { response } = await handler.handle(createBatchRequest(['/stream'])) const reader = response!.body!.pipeThrough(new DecompressionStream('gzip')).getReader() @@ -194,7 +204,9 @@ describe('batchResponseCompressionHandlerPlugin', () => { }) const { response } = await handler.handle(createBatchRequest(urls)) - const { response: uncompressed } = await handler.handle(createBatchRequest(urls, { 'accept-encoding': 'identity' })) + const { response: uncompressed } = await handler.handle( + createBatchRequest(urls, { 'accept-encoding': 'identity' }), + ) expect(response!.headers.get('content-encoding')).toBe('gzip') expect(response!.headers.get('content-type')).toBe('application/x-custom') @@ -209,10 +221,13 @@ describe('batchResponseCompressionHandlerPlugin', () => { ['with a binary subresponse', '/blob'], ['of json only', '/tiny'], ] as const)('leaves a batch %s below the threshold alone', async (_case, url) => { - const handler = createHandler({ threshold: 10 * 1024 }, { - ...router, - tiny: os.handler(() => 'ok'), - }) + const handler = createHandler( + { threshold: 10 * 1024 }, + { + ...router, + tiny: os.handler(() => 'ok'), + }, + ) const { response } = await handler.handle(createBatchRequest([url])) @@ -223,21 +238,29 @@ describe('batchResponseCompressionHandlerPlugin', () => { * A batch that fails as a whole answers with a plain message rather than subresponses. */ it('leaves a failed batch response alone', async () => { - const { response } = await createHandler().handle(new Request('https://example.com/__batch__', { - method: 'POST', - headers: { 'accept-encoding': 'gzip', 'content-type': 'application/json', 'orpc-batch': '1' }, - body: JSON.stringify({ not: 'an array of messages' }), - })) + const { response } = await createHandler().handle( + new Request('https://example.com/__batch__', { + method: 'POST', + headers: { + 'accept-encoding': 'gzip', + 'content-type': 'application/json', + 'orpc-batch': '1', + }, + body: JSON.stringify({ not: 'an array of messages' }), + }), + ) expect(response!.status).toBe(400) expect(response!.headers.get('content-encoding')).toBe(null) }) it('leaves responses that are not batch responses alone', async () => { - const { response } = await createHandler().handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { 'accept-encoding': 'gzip' }, - })) + const { response } = await createHandler().handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { 'accept-encoding': 'gzip' }, + }), + ) expect(response!.status).toBe(200) expect(response!.headers.get('content-encoding')).toBe(null) @@ -245,10 +268,12 @@ describe('batchResponseCompressionHandlerPlugin', () => { }) it('leaves a request that matched no procedure alone', async () => { - const { matched, response } = await createHandler().handle(new Request('https://example.com/absent', { - method: 'POST', - headers: { 'accept-encoding': 'gzip' }, - })) + const { matched, response } = await createHandler().handle( + new Request('https://example.com/absent', { + method: 'POST', + headers: { 'accept-encoding': 'gzip' }, + }), + ) expect(matched).toBe(false) expect(response).toBeUndefined() @@ -257,10 +282,16 @@ describe('batchResponseCompressionHandlerPlugin', () => { it('leaves an invalid batch response alone', async () => { const handler = new RPCHandler(router, { plugins: [new BatchResponseCompressionHandlerPlugin({ threshold: 0 })], - routingInterceptors: [async () => ({ - matched: true, - response: { status: 200, headers: { 'content-type': BATCH_CONTENT_TYPE }, body: 'invalid' }, - })], + routingInterceptors: [ + async () => ({ + matched: true, + response: { + status: 200, + headers: { 'content-type': BATCH_CONTENT_TYPE }, + body: 'invalid', + }, + }), + ], }) const { response } = await handler.handle(createBatchRequest(['/ping'])) @@ -270,14 +301,21 @@ describe('batchResponseCompressionHandlerPlugin', () => { }) it('leaves non-batch responses alone even when they carry the batch content type', async () => { - const handler = createHandler({}, { - export: os.handler(() => new File([largeValue], 'export.batch', { type: BATCH_CONTENT_TYPE })), - }) - - const { response } = await handler.handle(new Request('https://example.com/export', { - method: 'POST', - headers: { 'accept-encoding': 'gzip' }, - })) + const handler = createHandler( + {}, + { + export: os.handler( + () => new File([largeValue], 'export.batch', { type: BATCH_CONTENT_TYPE }), + ), + }, + ) + + const { response } = await handler.handle( + new Request('https://example.com/export', { + method: 'POST', + headers: { 'accept-encoding': 'gzip' }, + }), + ) expect(response!.headers.get('content-encoding')).toBe(null) expect(response!.headers.get('content-type')).toBe(BATCH_CONTENT_TYPE) @@ -290,14 +328,16 @@ describe('batchResponseCompressionHandlerPlugin', () => { const handler = new RPCHandler(router, { plugins: [new BatchResponseCompressionHandlerPlugin({ threshold: 0 })], - routingInterceptors: [async () => ({ - matched: true, - response: { - status: 206, - headers: { 'content-type': BATCH_CONTENT_TYPE, 'content-range': contentRange }, - body: new Blob([largeValue], { type: BATCH_CONTENT_TYPE }), - }, - })], + routingInterceptors: [ + async () => ({ + matched: true, + response: { + status: 206, + headers: { 'content-type': BATCH_CONTENT_TYPE, 'content-range': contentRange }, + body: new Blob([largeValue], { type: BATCH_CONTENT_TYPE }), + }, + }), + ], }) const { response } = await handler.handle(createBatchRequest(['/ping'])) @@ -314,26 +354,36 @@ describe('batchResponseCompressionHandlerPlugin', () => { ['deflate', 'deflate'], ['deflate-raw', 'deflate-raw'], ['gzip;q=0, *', 'deflate'], - ] as const)('given accept-encoding %s, compresses with %s', async (acceptEncoding, encoding) => { - const handler = createHandler({ encodings: ['gzip', 'deflate', 'deflate-raw'] }) + ] as const)( + 'given accept-encoding %s, compresses with %s', + async (acceptEncoding, encoding) => { + const handler = createHandler({ encodings: ['gzip', 'deflate', 'deflate-raw'] }) - const { response } = await handler.handle(createBatchRequest(['/ping'], { 'accept-encoding': acceptEncoding })) + const { response } = await handler.handle( + createBatchRequest(['/ping'], { 'accept-encoding': acceptEncoding }), + ) - expect(response!.headers.get('content-encoding')).toBe(encoding) - await expect(decompress(response!, encoding)).resolves.toContain(largeValue) - }) + expect(response!.headers.get('content-encoding')).toBe(encoding) + await expect(decompress(response!, encoding)).resolves.toContain(largeValue) + }, + ) it('prefers the first configured encoding the client accepts', async () => { const handler = createHandler({ encodings: ['deflate', 'gzip'] }) - const { response } = await handler.handle(createBatchRequest(['/ping'], { 'accept-encoding': 'gzip, deflate' })) + const { response } = await handler.handle( + createBatchRequest(['/ping'], { 'accept-encoding': 'gzip, deflate' }), + ) expect(response!.headers.get('content-encoding')).toBe('deflate') }) it.each([ ['the client accepts only an unsupported coding', { 'accept-encoding': 'br' }], - ['the client rejects every configured coding', { 'accept-encoding': 'gzip;q=0, deflate;q=0' }], + [ + 'the client rejects every configured coding', + { 'accept-encoding': 'gzip;q=0, deflate;q=0' }, + ], ['the client sends no accept-encoding', {}], ])('does not compress when %s', async (_case, headers) => { const { response } = await createHandler().handle(createBatchRequest(['/ping'], headers)) @@ -403,27 +453,47 @@ describe('batchResponseCompressionHandlerPlugin', () => { }) it.each([ - ['batch compression first', () => [new BatchResponseCompressionHandlerPlugin(), new ResponseCompressionHandlerPlugin({ threshold: 0 })]], - ['response compression first', () => [new ResponseCompressionHandlerPlugin({ threshold: 0 }), new BatchResponseCompressionHandlerPlugin()]], - ] as const)('coexists with the response compression plugin, %s, without compressing twice', async (_case, createPlugins) => { - const handler = new RPCHandler(router, { - plugins: [new BatchHandlerPlugin(), ...createPlugins()], - }) - - const { response } = await handler.handle(createBatchRequest(['/ping'])) - - expect(response!.headers.get('content-encoding')).toBe('gzip') - expect(response!.headers.get('vary')).toBe('accept-encoding') - await expect(decompress(response!, 'gzip')).resolves.toContain(largeValue) - }) + [ + 'batch compression first', + () => [ + new BatchResponseCompressionHandlerPlugin(), + new ResponseCompressionHandlerPlugin({ threshold: 0 }), + ], + ], + [ + 'response compression first', + () => [ + new ResponseCompressionHandlerPlugin({ threshold: 0 }), + new BatchResponseCompressionHandlerPlugin(), + ], + ], + ] as const)( + 'coexists with the response compression plugin, %s, without compressing twice', + async (_case, createPlugins) => { + const handler = new RPCHandler(router, { + plugins: [new BatchHandlerPlugin(), ...createPlugins()], + }) + + const { response } = await handler.handle(createBatchRequest(['/ping'])) + + expect(response!.headers.get('content-encoding')).toBe('gzip') + expect(response!.headers.get('vary')).toBe('accept-encoding') + await expect(decompress(response!, 'gzip')).resolves.toContain(largeValue) + }, + ) it('stops a batch cleanly when the client cancels mid-stream', async () => { - const handler = createHandler({}, { - stream: os.handler(async function* ({ signal }) { - yield largeValue - await new Promise((_, reject) => signal?.addEventListener('abort', () => reject(signal.reason))) - }), - }) + const handler = createHandler( + {}, + { + stream: os.handler(async function* ({ signal }) { + yield largeValue + await new Promise((_, reject) => + signal?.addEventListener('abort', () => reject(signal.reason)), + ) + }), + }, + ) const { response } = await handler.handle(createBatchRequest(['/stream'])) diff --git a/packages/node/src/batch-response-compression-handler-plugin.ts b/packages/node/src/batch-response-compression-handler-plugin.ts index 9112500fd1..29b50ca452 100644 --- a/packages/node/src/batch-response-compression-handler-plugin.ts +++ b/packages/node/src/batch-response-compression-handler-plugin.ts @@ -1,10 +1,19 @@ -import type { Context } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '@orpc/server/standard' -import type { StandardBodyHint, StandardHeaders } from '@standard-server/core' import { Duplex } from 'node:stream' import { constants, createDeflate, createDeflateRaw, createGzip } from 'node:zlib' -import { BATCH_HEADER, negotiateResponseCompressionEncoding, toCompressedHeaders } from '@orpc/server/plugins' + +import type { Context } from '@orpc/server' +import { + BATCH_HEADER, + negotiateResponseCompressionEncoding, + toCompressedHeaders, +} from '@orpc/server/plugins' +import type { + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, +} from '@orpc/server/standard' import { toArray } from '@orpc/shared' +import type { StandardBodyHint, StandardHeaders } from '@standard-server/core' import { flattenStandardHeader } from '@standard-server/core' import { toFetchBody } from '@standard-server/fetch' @@ -42,7 +51,9 @@ export interface BatchResponseCompressionHandlerPluginOptions { * * @see {@link https://orpc.dev/docs/plugins/batch-response-compression | Batch Response Compression Plugin} */ -export class BatchResponseCompressionHandlerPlugin implements StandardHandlerPlugin { +export class BatchResponseCompressionHandlerPlugin< + T extends Context, +> implements StandardHandlerPlugin { name = '~batch-response-compression' /** @@ -51,8 +62,14 @@ export class BatchResponseCompressionHandlerPlugin implements */ after: string[] = ['~batch'] - private readonly encodings: Exclude - private readonly threshold: Exclude + private readonly encodings: Exclude< + BatchResponseCompressionHandlerPluginOptions['encodings'], + undefined + > + private readonly threshold: Exclude< + BatchResponseCompressionHandlerPluginOptions['threshold'], + undefined + > constructor(options: BatchResponseCompressionHandlerPluginOptions = {}) { this.encodings = options.encodings ?? ['gzip', 'deflate'] @@ -60,7 +77,10 @@ export class BatchResponseCompressionHandlerPlugin implements } init(options: StandardHandlerOptions): StandardHandlerOptions { - const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ next, ...interceptorOptions }) => { + const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ + next, + ...interceptorOptions + }) => { const result = await next() if (!result.matched) { @@ -88,7 +108,11 @@ export class BatchResponseCompressionHandlerPlugin implements return result } - const encoding = negotiateResponseCompressionEncoding(response, interceptorOptions.request.headers, this.encodings) + const encoding = negotiateResponseCompressionEncoding( + response, + interceptorOptions.request.headers, + this.encodings, + ) if (encoding === undefined) { return result @@ -130,8 +154,7 @@ export class BatchResponseCompressionHandlerPlugin implements stream = body instanceof Blob ? body.stream() : body bodyHeaders = { 'standard-server': 'octet-stream' satisfies StandardBodyHint } - } - else { + } else { return result } @@ -150,10 +173,7 @@ export class BatchResponseCompressionHandlerPlugin implements return { ...options, - routingInterceptors: [ - routingInterceptor, - ...toArray(options.routingInterceptors), - ], + routingInterceptors: [routingInterceptor, ...toArray(options.routingInterceptors)], } } } @@ -173,11 +193,12 @@ function createFlushingCompressionStream( ): CompressionStreamPair { const zlibOptions = { flush: constants.Z_SYNC_FLUSH } - const compressor = encoding === 'gzip' - ? createGzip(zlibOptions) - : encoding === 'deflate' - ? createDeflate(zlibOptions) - : createDeflateRaw(zlibOptions) + const compressor = + encoding === 'gzip' + ? createGzip(zlibOptions) + : encoding === 'deflate' + ? createDeflate(zlibOptions) + : createDeflateRaw(zlibOptions) return Duplex.toWeb(compressor) as CompressionStreamPair } diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8a6506cddf..7adeb50d73 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,3 +1,3 @@ -export * from './batch-response-compression-handler-plugin' -export * from './static-file-handler-plugin' -export * from './tmp-file-upload-handler-plugin' +export * from './batch-response-compression-handler-plugin.ts' +export * from './static-file-handler-plugin.ts' +export * from './tmp-file-upload-handler-plugin.ts' diff --git a/packages/node/src/multipart.test.ts b/packages/node/src/multipart.test.ts index 91ee970eb7..d48f0fb8ad 100644 --- a/packages/node/src/multipart.test.ts +++ b/packages/node/src/multipart.test.ts @@ -1,19 +1,24 @@ -import type { MultipartPart } from './multipart' import { Buffer } from 'node:buffer' import { setTimeout as sleep } from 'node:timers/promises' -import { parseHeaderParameters, parseMultipart } from './multipart' + +import type { MultipartPart } from './multipart.ts' +import { parseHeaderParameters, parseMultipart } from './multipart.ts' interface CollectedPart extends MultipartPart { content: Buffer } -function toStream(body: Buffer, chunkSize: number, delivery: 'sync' | 'async' = 'sync'): ReadableStream { +function toStream( + body: Buffer, + chunkSize: number, + delivery: 'sync' | 'async' = 'sync', +): ReadableStream { let offset = 0 return new ReadableStream({ async pull(controller) { if (delivery === 'async') { - await new Promise(resolve => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) } if (offset >= body.length) { @@ -21,12 +26,17 @@ function toStream(body: Buffer, chunkSize: number, delivery: 'sync' | 'async' = return } - controller.enqueue(body.subarray(offset, offset += chunkSize)) + controller.enqueue(body.subarray(offset, (offset += chunkSize))) }, }) } -async function collect(body: Buffer, boundary: string, chunkSize: number, delivery?: 'sync' | 'async'): Promise { +async function collect( + body: Buffer, + boundary: string, + chunkSize: number, + delivery?: 'sync' | 'async', +): Promise { const parts: CollectedPart[] = [] await parseMultipart(toStream(body, chunkSize, delivery), boundary, (part) => { @@ -46,11 +56,11 @@ async function collect(body: Buffer, boundary: string, chunkSize: number, delive } function buildBody(boundary: string, parts: string[][]): Buffer { - return Buffer.from([ - ...parts.map(lines => [`--${boundary}`, ...lines].join('\r\n')), - `--${boundary}--`, - '', - ].join('\r\n')) + return Buffer.from( + [...parts.map((lines) => [`--${boundary}`, ...lines].join('\r\n')), `--${boundary}--`, ''].join( + '\r\n', + ), + ) } describe('parseMultipart', () => { @@ -59,27 +69,54 @@ describe('parseMultipart', () => { it('parses fields and files', async () => { const body = buildBody(boundary, [ ['Content-Disposition: form-data; name="field"', '', 'value'], - ['Content-Disposition: form-data; name="file"; filename="a.txt"', 'Content-Type: text/plain', '', 'file content'], + [ + 'Content-Disposition: form-data; name="file"; filename="a.txt"', + 'Content-Type: text/plain', + '', + 'file content', + ], ]) for (const chunkSize of [1, 2, 3, 4, 7, 16, 61, 1024, body.length]) { const parts = await collect(body, boundary, chunkSize) expect(parts).toHaveLength(2) - expect(parts[0]).toMatchObject({ name: 'field', filename: undefined, type: undefined, headerSize: Buffer.byteLength('Content-Disposition: form-data; name="field"\r\n\r\n') }) + expect(parts[0]).toMatchObject({ + name: 'field', + filename: undefined, + type: undefined, + headerSize: Buffer.byteLength('Content-Disposition: form-data; name="field"\r\n\r\n'), + }) expect(parts[0]!.content.toString()).toBe('value') - expect(parts[1]).toMatchObject({ name: 'file', filename: 'a.txt', type: 'text/plain', headerSize: Buffer.byteLength('Content-Disposition: form-data; name="file"; filename="a.txt"\r\nContent-Type: text/plain\r\n\r\n') }) + expect(parts[1]).toMatchObject({ + name: 'file', + filename: 'a.txt', + type: 'text/plain', + headerSize: Buffer.byteLength( + 'Content-Disposition: form-data; name="file"; filename="a.txt"\r\nContent-Type: text/plain\r\n\r\n', + ), + }) expect(parts[1]!.content.toString()).toBe('file content') } }) it('keeps content-type parameters, case-insensitive dispositions, and ignores other part headers', async () => { const body = buildBody(boundary, [ - ['Content-Disposition: FORM-DATA; name="file"; filename="data.csv"', 'Content-Type: text/csv; charset=utf-8', 'Content-Transfer-Encoding: 7bit', '', 'a,b'], + [ + 'Content-Disposition: FORM-DATA; name="file"; filename="data.csv"', + 'Content-Type: text/csv; charset=utf-8', + 'Content-Transfer-Encoding: 7bit', + '', + 'a,b', + ], ]) const parts = await collect(body, boundary, 5) - expect(parts[0]).toMatchObject({ name: 'file', filename: 'data.csv', type: 'text/csv; charset=utf-8' }) + expect(parts[0]).toMatchObject({ + name: 'file', + filename: 'data.csv', + type: 'text/csv; charset=utf-8', + }) expect(parts[0]!.content.toString()).toBe('a,b') }) @@ -113,15 +150,17 @@ describe('parseMultipart', () => { }) it('ignores the preamble, the epilogue, and transport padding after boundaries', async () => { - const body = Buffer.from([ - 'this is a preamble the parser must skip', - `--${boundary} \t `, - 'Content-Disposition: form-data; name="field"', - '', - 'value', - `--${boundary}--`, - 'this is an epilogue the parser must ignore', - ].join('\r\n')) + const body = Buffer.from( + [ + 'this is a preamble the parser must skip', + `--${boundary} \t `, + 'Content-Disposition: form-data; name="field"', + '', + 'value', + `--${boundary}--`, + 'this is an epilogue the parser must ignore', + ].join('\r\n'), + ) for (const chunkSize of [1, 9, body.length]) { const parts = await collect(body, boundary, chunkSize) @@ -132,7 +171,9 @@ describe('parseMultipart', () => { it('drops transport padding as it arrives instead of buffering it', async () => { // Buffered padding is re-copied and rescanned on every chunk, which would not finish at this size - const body = Buffer.from(`--${boundary}${' '.repeat(8 * 1024 * 1024)}\r\nContent-Disposition: form-data; name="a"\r\n\r\nv\r\n--${boundary}--\r\n`) + const body = Buffer.from( + `--${boundary}${' '.repeat(8 * 1024 * 1024)}\r\nContent-Disposition: form-data; name="a"\r\n\r\nv\r\n--${boundary}--\r\n`, + ) const parts = await collect(body, boundary, 4096) expect(parts).toHaveLength(1) @@ -142,7 +183,9 @@ describe('parseMultipart', () => { it('limits part headers to 16 KiB however the chunks split them', async () => { const withHeaderBlock = (size: number): Buffer => { const disposition = 'Content-Disposition: form-data; name=""' - return buildBody(boundary, [[disposition.replace('""', `"${'a'.repeat(size - disposition.length)}"`), '', 'v']]) + return buildBody(boundary, [ + [disposition.replace('""', `"${'a'.repeat(size - disposition.length)}"`), '', 'v'], + ]) } const atLimit = withHeaderBlock(16 * 1024) @@ -151,34 +194,53 @@ describe('parseMultipart', () => { // Chunks ending inside the terminator must not count its first bytes against the limit for (const chunkSize of [997, terminator + 1, terminator + 2, terminator + 3, atLimit.length]) { expect(await collect(atLimit, boundary, chunkSize)).toHaveLength(1) - await expect(collect(withHeaderBlock(16 * 1024 + 1), boundary, chunkSize)).rejects.toThrow('maximum allowed size') + await expect(collect(withHeaderBlock(16 * 1024 + 1), boundary, chunkSize)).rejects.toThrow( + 'maximum allowed size', + ) } const oversized = withHeaderBlock(60 * 1024) - await expect(collect(oversized, boundary, oversized.length)).rejects.toThrow('maximum allowed size') + await expect(collect(oversized, boundary, oversized.length)).rejects.toThrow( + 'maximum allowed size', + ) }) it('tolerates leniencies other parsers disagree on', async () => { // Space before the header colon, accepted by the standard parser - const spacedColon = buildBody(boundary, [['Content-Disposition : form-data; name="a"', '', 'va']]) + const spacedColon = buildBody(boundary, [ + ['Content-Disposition : form-data; name="a"', '', 'va'], + ]) expect((await collect(spacedColon, boundary, 5))[0]).toMatchObject({ name: 'a' }) // Junk after the close delimiter is epilogue - const trailingJunk = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="a"\r\n\r\nva\r\n--${boundary}--tail junk`) + const trailingJunk = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="a"\r\n\r\nva\r\n--${boundary}--tail junk`, + ) expect((await collect(trailingJunk, boundary, 5))[0]!.content.toString()).toBe('va') // filename* is forbidden in multipart/form-data and stays ignored, leaving a field - const extendedFilename = buildBody(boundary, [[`Content-Disposition: form-data; name="f"; filename*=utf-8''ext.txt`, '', 'va']]) - expect((await collect(extendedFilename, boundary, 5))[0]).toMatchObject({ name: 'f', filename: undefined }) + const extendedFilename = buildBody(boundary, [ + [`Content-Disposition: form-data; name="f"; filename*=utf-8''ext.txt`, '', 'va'], + ]) + expect((await collect(extendedFilename, boundary, 5))[0]).toMatchObject({ + name: 'f', + filename: undefined, + }) // The first occurrence of a duplicate parameter wins - const duplicated = buildBody(boundary, [['Content-Disposition: form-data; name="first"; name="second"', '', 'va']]) + const duplicated = buildBody(boundary, [ + ['Content-Disposition: form-data; name="first"; name="second"', '', 'va'], + ]) expect((await collect(duplicated, boundary, 5))[0]).toMatchObject({ name: 'first' }) }) it('decodes the serialization escapes in names and filenames', async () => { const body = buildBody(boundary, [ - ['Content-Disposition: form-data; name="quo%22ted%0Aname"; filename="my %22quoted%22%0D%0Afile.txt"', '', 'v'], + [ + 'Content-Disposition: form-data; name="quo%22ted%0Aname"; filename="my %22quoted%22%0D%0Afile.txt"', + '', + 'v', + ], ]) const parts = await collect(body, boundary, 4) @@ -222,16 +284,22 @@ describe('parseMultipart', () => { await expect(collect(missingDisposition, boundary, 3)).rejects.toThrow('content-disposition') // The standard parser accepts no disposition type other than form-data - const wrongDisposition = buildBody(boundary, [['Content-Disposition: attachment; name="a"', '', 'v']]) + const wrongDisposition = buildBody(boundary, [ + ['Content-Disposition: attachment; name="a"', '', 'v'], + ]) await expect(collect(wrongDisposition, boundary, 3)).rejects.toThrow('must be form-data') - const missingName = buildBody(boundary, [['Content-Disposition: form-data; filename="a.txt"', '', 'v']]) + const missingName = buildBody(boundary, [ + ['Content-Disposition: form-data; filename="a.txt"', '', 'v'], + ]) await expect(collect(missingName, boundary, 3)).rejects.toThrow('name parameter') const bareDisposition = buildBody(boundary, [['Content-Disposition: form-data', '', 'v']]) await expect(collect(bareDisposition, boundary, 3)).rejects.toThrow('name parameter') - const headerWithoutColon = buildBody(boundary, [['Content-Disposition: form-data; name="a"', 'not-a-header', '', 'v']]) + const headerWithoutColon = buildBody(boundary, [ + ['Content-Disposition: form-data; name="a"', 'not-a-header', '', 'v'], + ]) await expect(collect(headerWithoutColon, boundary, 3)).rejects.toThrow('malformed part header') const missingHeaders = Buffer.from(`--${boundary}\r\n\r\nv\r\n--${boundary}--\r\n`) @@ -242,7 +310,9 @@ describe('parseMultipart', () => { await expect(collect(garbageAfterBoundary, boundary, 3)).rejects.toThrow('expected CRLF') // The dashes of a close delimiter follow the boundary directly, never the transport padding - const paddedCloseDelimiter = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="a"\r\n\r\nv\r\n--${boundary} \t--\r\n`) + const paddedCloseDelimiter = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="a"\r\n\r\nv\r\n--${boundary} \t--\r\n`, + ) await expect(collect(paddedCloseDelimiter, boundary, 3)).rejects.toThrow('expected CRLF') const falseDelimiterInBody = buildBody(boundary, [ @@ -251,19 +321,30 @@ describe('parseMultipart', () => { await expect(collect(falseDelimiterInBody, boundary, 3)).rejects.toThrow('expected CRLF') // Multipart line endings are strictly CRLF - const lfOnly = Buffer.from(buildBody(boundary, [['Content-Disposition: form-data; name="a"', '', 'v']]).toString().replaceAll('\r\n', '\n')) + const lfOnly = Buffer.from( + buildBody(boundary, [['Content-Disposition: form-data; name="a"', '', 'v']]) + .toString() + .replaceAll('\r\n', '\n'), + ) await expect(collect(lfOnly, boundary, 3)).rejects.toThrow(TypeError) await expect(collect(Buffer.alloc(0), boundary, 3)).rejects.toThrow('unexpected end of body') - const unboundedHeader = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${'a'.repeat(20 * 1024)}"`) + const unboundedHeader = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${'a'.repeat(20 * 1024)}"`, + ) await expect(collect(unboundedHeader, boundary, 1024)).rejects.toThrow('maximum allowed size') }) it('rejects truncated bodies wherever the cut lands', async () => { const body = buildBody(boundary, [ ['Content-Disposition: form-data; name="field"', '', 'value'], - ['Content-Disposition: form-data; name="file"; filename="a.txt"', 'Content-Type: text/plain', '', 'file content'], + [ + 'Content-Disposition: form-data; name="file"; filename="a.txt"', + 'Content-Type: text/plain', + '', + 'file content', + ], ]) // Anywhere before the closing `--` of the final delimiter is incomplete @@ -273,13 +354,19 @@ describe('parseMultipart', () => { }) describe('differential against the standard parser', () => { - async function expectMatchesStandardParser(form: FormData, chunkSizes: number[], delivery?: 'sync' | 'async'): Promise { + async function expectMatchesStandardParser( + form: FormData, + chunkSizes: number[], + delivery?: 'sync' | 'async', + ): Promise { const response = new Response(form) const contentType = response.headers.get('content-type')! const body = Buffer.from(await response.arrayBuffer()) const boundary = parseHeaderParameters(contentType).get('boundary')! - const expected = await new Response(body, { headers: { 'content-type': contentType } }).formData() + const expected = await new Response(body, { + headers: { 'content-type': contentType }, + }).formData() const expectedEntries = [...expected] for (const chunkSize of chunkSizes) { @@ -296,8 +383,7 @@ describe('parseMultipart', () => { if (typeof expectedValue === 'string') { expect(part.filename).toBeUndefined() expect(part.content.toString()).toBe(expectedValue) - } - else { + } else { expect(part.type ?? 'text/plain').toBe(expectedValue.type) expect(part.content.equals(Buffer.from(await expectedValue.arrayBuffer()))).toBe(true) } @@ -313,7 +399,12 @@ describe('parseMultipart', () => { form.append('quo"ted\nname', 'escaped by the serializer') form.append('file', new File(['file content'], 'tệp tin 🚀.txt', { type: 'text/plain' })) form.append('escapes', new File(['x'], 'my "quoted"\r\nfile.txt', { type: 'text/plain' })) - form.append('binary', new File([new Uint8Array([0, 1, 2, 253, 254, 255])], 'raw.bin', { type: 'application/octet-stream' })) + form.append( + 'binary', + new File([new Uint8Array([0, 1, 2, 253, 254, 255])], 'raw.bin', { + type: 'application/octet-stream', + }), + ) form.append('empty', new File([], 'empty.bin')) await expectMatchesStandardParser(form, [1, 2, 3, 7, 64, 1024, 1024 * 1024]) @@ -322,13 +413,27 @@ describe('parseMultipart', () => { it('parses randomized forms identically at randomized chunk sizes', async () => { // Deterministic PRNG so a failure reproduces - let seed = 0x2F6E2B1 + let seed = 0x2f6e2b1 const random = (): number => { seed = (seed * 1664525 + 1013904223) % 4294967296 return seed / 4294967296 } - const fragments = ['\r', '\n', '\r\n', '--', '\r\n--', 'formdata-', 'a', '"', ';', 'é', '🚀', '\0', '\r\n--formdata'] + const fragments = [ + '\r', + '\n', + '\r\n', + '--', + '\r\n--', + 'formdata-', + 'a', + '"', + ';', + 'é', + '🚀', + '\0', + '\r\n--formdata', + ] for (let iteration = 0; iteration < 25; iteration++) { const form = new FormData() @@ -343,9 +448,13 @@ describe('parseMultipart', () => { if (random() < 0.5) { form.append(`field-${i}`, content) - } - else { - form.append(`file-${i}`, new File([content], `file-${i}.bin`, { type: random() < 0.5 ? 'application/octet-stream' : '' })) + } else { + form.append( + `file-${i}`, + new File([content], `file-${i}.bin`, { + type: random() < 0.5 ? 'application/octet-stream' : '', + }), + ) } } @@ -358,14 +467,33 @@ describe('parseMultipart', () => { describe('parseHeaderParameters', () => { it('parses tokens and quoted strings, keeping backslashes literal', () => { - expect(parseHeaderParameters('multipart/form-data; boundary=----abc=123')).toEqual(new Map([['boundary', '----abc=123']])) - expect(parseHeaderParameters('multipart/form-data; boundary="b;=x"; charset=utf-8')).toEqual(new Map([['boundary', 'b;=x'], ['charset', 'utf-8']])) - expect(parseHeaderParameters('form-data; name="a\\b"; filename="c\\\\d.txt"')).toEqual(new Map([['name', 'a\\b'], ['filename', 'c\\\\d.txt']])) - expect(parseHeaderParameters('form-data;\t name = spaced ; filename=trailing ')).toEqual(new Map([['name', 'spaced'], ['filename', 'trailing']])) + expect(parseHeaderParameters('multipart/form-data; boundary=----abc=123')).toEqual( + new Map([['boundary', '----abc=123']]), + ) + expect(parseHeaderParameters('multipart/form-data; boundary="b;=x"; charset=utf-8')).toEqual( + new Map([ + ['boundary', 'b;=x'], + ['charset', 'utf-8'], + ]), + ) + expect(parseHeaderParameters('form-data; name="a\\b"; filename="c\\\\d.txt"')).toEqual( + new Map([ + ['name', 'a\\b'], + ['filename', 'c\\\\d.txt'], + ]), + ) + expect(parseHeaderParameters('form-data;\t name = spaced ; filename=trailing ')).toEqual( + new Map([ + ['name', 'spaced'], + ['filename', 'trailing'], + ]), + ) }) it('keeps the first occurrence of a parameter', () => { - expect(parseHeaderParameters('form-data; name=first; name=second')).toEqual(new Map([['name', 'first']])) + expect(parseHeaderParameters('form-data; name=first; name=second')).toEqual( + new Map([['name', 'first']]), + ) }) it('skips parameters without a value', () => { @@ -376,13 +504,17 @@ describe('parseHeaderParameters', () => { it('stays linear on a long run of parameters without values', () => { // Searching past each semicolon for an equals sign would not finish at this size - expect(parseHeaderParameters(`form-data${'; x'.repeat(256 * 1024)}; name=a`)).toEqual(new Map([['name', 'a']])) + expect(parseHeaderParameters(`form-data${'; x'.repeat(256 * 1024)}; name=a`)).toEqual( + new Map([['name', 'a']]), + ) expect(parseHeaderParameters(`form-data${';'.repeat(1024 * 1024)}`)).toEqual(new Map()) }) it('returns nothing without parameters and survives malformed input', () => { expect(parseHeaderParameters('form-data')).toEqual(new Map()) expect(parseHeaderParameters('form-data; ')).toEqual(new Map()) - expect(parseHeaderParameters('form-data; name="unterminated')).toEqual(new Map([['name', 'unterminated']])) + expect(parseHeaderParameters('form-data; name="unterminated')).toEqual( + new Map([['name', 'unterminated']]), + ) }) }) diff --git a/packages/node/src/multipart.ts b/packages/node/src/multipart.ts index 3b0168d8c2..6dc9c997df 100644 --- a/packages/node/src/multipart.ts +++ b/packages/node/src/multipart.ts @@ -118,7 +118,7 @@ export async function parseMultipart( } // A close delimiter is `--` immediately after the boundary, everything after it is epilogue - if (buffer[0] === 0x2D && buffer[1] === 0x2D) { + if (buffer[0] === 0x2d && buffer[1] === 0x2d) { done = true buffer = Buffer.alloc(0) return @@ -140,7 +140,7 @@ export async function parseMultipart( return } - if (buffer[0] !== 0x0D || buffer[1] !== 0x0A) { + if (buffer[0] !== 0x0d || buffer[1] !== 0x0a) { throw new TypeError('Invalid multipart body: expected CRLF after a boundary delimiter') } @@ -150,8 +150,10 @@ export async function parseMultipart( } // A part without headers can never carry the required content-disposition - if (buffer[0] === 0x0D && buffer[1] === 0x0A) { - throw new TypeError('Invalid multipart body: part is missing the content-disposition header') + if (buffer[0] === 0x0d && buffer[1] === 0x0a) { + throw new TypeError( + 'Invalid multipart body: part is missing the content-disposition header', + ) } const headerWindow = MAX_PART_HEADER_SIZE + HEADER_BLOCK_END.length @@ -159,7 +161,9 @@ export async function parseMultipart( if (headerEnd === -1) { if (buffer.length >= headerWindow) { - throw new TypeError('Invalid multipart body: part headers exceed the maximum allowed size') + throw new TypeError( + 'Invalid multipart body: part headers exceed the maximum allowed size', + ) } return @@ -179,9 +183,10 @@ export async function parseMultipart( } // A bare view suffices when nothing is retained, skipping a copy - buffer = buffer.length === 0 - ? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) - : Buffer.concat([buffer, chunk]) + buffer = + buffer.length === 0 + ? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + : Buffer.concat([buffer, chunk]) await process() } @@ -205,8 +210,7 @@ function parsePartHeaders(block: string, headerSize: number): MultipartPart { if (name === 'content-disposition') { contentDisposition = line.slice(colon + 1).trim() - } - else if (name === 'content-type') { + } else if (name === 'content-type') { contentType = line.slice(colon + 1).trim() } } @@ -217,7 +221,11 @@ function parsePartHeaders(block: string, headerSize: number): MultipartPart { // The standard parser accepts no disposition type other than form-data, case-insensitively const semicolon = contentDisposition.indexOf(';') - const dispositionType = (semicolon === -1 ? contentDisposition : contentDisposition.slice(0, semicolon)).trim().toLowerCase() + const dispositionType = ( + semicolon === -1 ? contentDisposition : contentDisposition.slice(0, semicolon) + ) + .trim() + .toLowerCase() if (dispositionType !== 'form-data') { throw new TypeError('Invalid multipart body: content-disposition must be form-data') @@ -227,7 +235,9 @@ function parsePartHeaders(block: string, headerSize: number): MultipartPart { const name = parameters.get('name') if (name === undefined) { - throw new TypeError('Invalid multipart body: content-disposition header is missing the name parameter') + throw new TypeError( + 'Invalid multipart body: content-disposition header is missing the name parameter', + ) } const filename = parameters.get('filename') @@ -298,8 +308,7 @@ export function parseHeaderParameters(header: string): Map { const closing = header.indexOf('"', index + 1) value = closing === -1 ? header.slice(index + 1) : header.slice(index + 1, closing) index = closing === -1 ? -1 : header.indexOf(';', closing + 1) - } - else { + } else { const semicolon = header.indexOf(';', index) value = (semicolon === -1 ? header.slice(index) : header.slice(index, semicolon)).trim() index = semicolon diff --git a/packages/node/src/static-file-handler-plugin.test.ts b/packages/node/src/static-file-handler-plugin.test.ts index 374d8c281d..cb74c6ee53 100644 --- a/packages/node/src/static-file-handler-plugin.test.ts +++ b/packages/node/src/static-file-handler-plugin.test.ts @@ -1,20 +1,30 @@ -import type { IncomingMessage, ServerResponse } from 'node:http' -import type { AddressInfo } from 'node:net' -import type { StaticFileHandlerPluginOptions } from './static-file-handler-plugin' import { Buffer } from 'node:buffer' -import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import type { IncomingMessage, ServerResponse } from 'node:http' import { createServer, request as httpRequest } from 'node:http' +import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' import { brotliCompressSync, gzipSync } from 'node:zlib' + import { os } from '@orpc/server' import { RPCHandler as FetchRPCHandler } from '@orpc/server/fetch' import { RPCHandler } from '@orpc/server/node' import { ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' import * as sharedModule from '@orpc/shared' import request from 'supertest' -import { StaticFileHandlerPlugin } from './static-file-handler-plugin' + +import type { StaticFileHandlerPluginOptions } from './static-file-handler-plugin.ts' +import { StaticFileHandlerPlugin } from './static-file-handler-plugin.ts' describe('staticFileHandlerPlugin', () => { let baseDir: string @@ -63,7 +73,10 @@ describe('staticFileHandlerPlugin', () => { rmSync(baseDir, { recursive: true, force: true }) }) - function createAgent(handler: RPCHandler>, options: { prefix?: `/${string}` } = {}) { + function createAgent( + handler: RPCHandler>, + options: { prefix?: `/${string}` } = {}, + ) { return request(async (req: IncomingMessage, response: ServerResponse) => { const result = await handler.handle(req as any, response as any, { context: {}, ...options }) @@ -74,10 +87,16 @@ describe('staticFileHandlerPlugin', () => { }) } - function createStaticAgent(pluginOptions: Partial = {}, handleOptions: { prefix?: `/${string}` } = {}) { - const handler = new RPCHandler({}, { - plugins: [new StaticFileHandlerPlugin({ rootDir, ...pluginOptions })], - }) + function createStaticAgent( + pluginOptions: Partial = {}, + handleOptions: { prefix?: `/${string}` } = {}, + ) { + const handler = new RPCHandler( + {}, + { + plugins: [new StaticFileHandlerPlugin({ rootDir, ...pluginOptions })], + }, + ) return createAgent(handler, handleOptions) } @@ -88,9 +107,12 @@ describe('staticFileHandlerPlugin', () => { * the plugin through a client that sends the target verbatim. */ async function createRawClient(pluginOptions: Partial = {}) { - const handler = new RPCHandler({}, { - plugins: [new StaticFileHandlerPlugin({ rootDir, ...pluginOptions })], - }) + const handler = new RPCHandler( + {}, + { + plugins: [new StaticFileHandlerPlugin({ rootDir, ...pluginOptions })], + }, + ) const server = createServer(async (req, res) => { const result = await handler.handle(req, res, { context: {} }) @@ -101,23 +123,24 @@ describe('staticFileHandlerPlugin', () => { } }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const { port } = server.address() as AddressInfo return { close: () => { server.close() }, - get: (target: string) => new Promise<{ status: number | undefined, body: string }>((resolve, reject) => { - const req = httpRequest({ host: '127.0.0.1', port, path: target }, (res) => { - let body = '' - res.setEncoding('utf8') - res.on('data', chunk => body += chunk) - res.on('end', () => resolve({ status: res.statusCode, body })) - }) - req.on('error', reject) - req.end() - }), + get: (target: string) => + new Promise<{ status: number | undefined; body: string }>((resolve, reject) => { + const req = httpRequest({ host: '127.0.0.1', port, path: target }, (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk) => (body += chunk)) + res.on('end', () => resolve({ status: res.statusCode, body })) + }) + req.on('error', reject) + req.end() + }), } } @@ -131,7 +154,9 @@ describe('staticFileHandlerPlugin', () => { expect(res.headers['cache-control']).toBe('public, max-age=0') expect(res.headers['accept-ranges']).toBe('bytes') expect(res.headers.etag).toMatch(/^"[0-9a-f]+-[0-9a-f]+"$/) - expect(res.headers['last-modified']).toBe(statSync(path.join(rootDir, 'hello.txt')).mtime.toUTCString()) + expect(res.headers['last-modified']).toBe( + statSync(path.join(rootDir, 'hello.txt')).mtime.toUTCString(), + ) // Guards against a Blob body, which would make the adapter attach a content-disposition expect(res.headers['content-disposition']).toBeUndefined() }) @@ -221,16 +246,21 @@ describe('staticFileHandlerPlugin', () => { }) it('prefers matched procedures over files', async () => { - const handler = new RPCHandler({ - ping: os.handler(() => 'pong'), - }, { - allowMethods: ['GET'], - plugins: [new StaticFileHandlerPlugin({ rootDir })], - }) + const handler = new RPCHandler( + { + ping: os.handler(() => 'pong'), + }, + { + allowMethods: ['GET'], + plugins: [new StaticFileHandlerPlugin({ rootDir })], + }, + ) writeFileSync(path.join(rootDir, 'ping'), 'file content') - const res = await createAgent(handler).get(`/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) + const res = await createAgent(handler).get( + `/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + ) expect(res.status).toBe(200) expect(res.text).toContain('pong') @@ -291,7 +321,9 @@ describe('staticFileHandlerPlugin', () => { }) it('responds 304 when not modified since if-modified-since', async () => { - const res = await createStaticAgent().get('/hello.txt').set('if-modified-since', helloLastModified) + const res = await createStaticAgent() + .get('/hello.txt') + .set('if-modified-since', helloLastModified) expect(res.status).toBe(304) }) @@ -304,13 +336,19 @@ describe('staticFileHandlerPlugin', () => { }) it('if-none-match takes precedence over if-modified-since', async () => { - const res = await createStaticAgent().get('/hello.txt').set('if-none-match', '"different"').set('if-modified-since', helloLastModified) + const res = await createStaticAgent() + .get('/hello.txt') + .set('if-none-match', '"different"') + .set('if-modified-since', helloLastModified) expect(res.status).toBe(200) }) it('revalidates to 304 even when the client sends cache-control no-cache, like fetch does', async () => { - const res = await createStaticAgent().get('/hello.txt').set('if-none-match', helloEtag).set('cache-control', 'no-cache') + const res = await createStaticAgent() + .get('/hello.txt') + .set('if-none-match', helloEtag) + .set('cache-control', 'no-cache') expect(res.status).toBe(304) }) @@ -370,14 +408,20 @@ describe('staticFileHandlerPlugin', () => { }) it('ignores the range when if-range does not match', async () => { - const res = await createStaticAgent().get('/data.bin').set('range', 'bytes=0-3').set('if-range', new Date(Date.now() - 100_000_000).toUTCString()) + const res = await createStaticAgent() + .get('/data.bin') + .set('range', 'bytes=0-3') + .set('if-range', new Date(Date.now() - 100_000_000).toUTCString()) expect(res.status).toBe(200) expect(res.headers['content-length']).toBe('10') }) it('applies the range when if-range matches the last modified date', async () => { - const res = await createStaticAgent().get('/data.bin').set('range', 'bytes=0-3').set('if-range', statSync(path.join(rootDir, 'data.bin')).mtime.toUTCString()) + const res = await createStaticAgent() + .get('/data.bin') + .set('range', 'bytes=0-3') + .set('if-range', statSync(path.join(rootDir, 'data.bin')).mtime.toUTCString()) expect(res.status).toBe(206) }) @@ -390,12 +434,18 @@ describe('staticFileHandlerPlugin', () => { expect(matching.status).toBe(206) expect(matching.headers['content-range']).toBe('bytes 0-3/10') - const mismatching = await agent.get('/data.bin').set('range', 'bytes=0-3').set('if-range', '"other"') + const mismatching = await agent + .get('/data.bin') + .set('range', 'bytes=0-3') + .set('if-range', '"other"') expect(mismatching.status).toBe(200) expect(mismatching.headers['content-length']).toBe('10') // A weak tag can never satisfy the strong comparison if-range requires - const weak = await agent.get('/data.bin').set('range', 'bytes=0-3').set('if-range', `W/${etag}`) + const weak = await agent + .get('/data.bin') + .set('range', 'bytes=0-3') + .set('if-range', `W/${etag}`) expect(weak.status).toBe(200) }) @@ -408,7 +458,10 @@ describe('staticFileHandlerPlugin', () => { utimesSync(filePath, date, date) onTestFinished(() => rmSync(filePath, { force: true })) - const res = await createStaticAgent().get('/tag-date.bin').set('range', 'bytes=0-3').set('if-range', tag) + const res = await createStaticAgent() + .get('/tag-date.bin') + .set('range', 'bytes=0-3') + .set('if-range', tag) expect(res.status).toBe(200) expect(res.headers['content-length']).toBe('10') }) @@ -496,60 +549,80 @@ describe('staticFileHandlerPlugin', () => { describe('security', () => { // Sent verbatim, since supertest would resolve dot segments itself. secret.txt really exists // one directory above the root, so a 404 here asserts a blocked escape. - it.for([false, true])('blocks directory traversal (dotfiles: %s)', async (dotfiles, { onTestFinished }) => { - const client = await createRawClient({ dotfiles }) - onTestFinished(() => client.close()) - - const urls = [ - '/../secret.txt', - '/%2e%2e/secret.txt', - '/%2E%2E/secret.txt', - '/..%2fsecret.txt', - '/%2e%2e%2fsecret.txt', - '/%252e%252e/secret.txt', - '/..%c0%af/secret.txt', - '/..%c0%afsecret.txt', - '/nested/%2e%2e/%2e%2e/secret.txt', - '/nested/../../secret.txt', - '/foo%5c..%5cbar.txt', - '/foo%2fbar.txt', - '/....//secret.txt', - '/..;/secret.txt', - '/..%00/secret.txt', - '/%c0%ae%c0%ae/secret.txt', - '/..%5csecret.txt', - '/%2e%2e%5csecret.txt', - '/..%252fsecret.txt', - '/.%2e/secret.txt', - '/./../secret.txt', - ] - - for (const url of urls) { - const res = await client.get(url) - expect(res.status, url).toBe(404) - expect(res.body, url).toBe('not matched') - } - }) + it.for([false, true])( + 'blocks directory traversal (dotfiles: %s)', + async (dotfiles, { onTestFinished }) => { + const client = await createRawClient({ dotfiles }) + onTestFinished(() => client.close()) + + const urls = [ + '/../secret.txt', + '/%2e%2e/secret.txt', + '/%2E%2E/secret.txt', + '/..%2fsecret.txt', + '/%2e%2e%2fsecret.txt', + '/%252e%252e/secret.txt', + '/..%c0%af/secret.txt', + '/..%c0%afsecret.txt', + '/nested/%2e%2e/%2e%2e/secret.txt', + '/nested/../../secret.txt', + '/foo%5c..%5cbar.txt', + '/foo%2fbar.txt', + '/....//secret.txt', + '/..;/secret.txt', + '/..%00/secret.txt', + '/%c0%ae%c0%ae/secret.txt', + '/..%5csecret.txt', + '/%2e%2e%5csecret.txt', + '/..%252fsecret.txt', + '/.%2e/secret.txt', + '/./../secret.txt', + ] + + for (const url of urls) { + const res = await client.get(url) + expect(res.status, url).toBe(404) + expect(res.body, url).toBe('not matched') + } + }, + ) // Win32 strips trailing dots and spaces, so any segment of only those can mean `..` there. // A refused path falls through before the fallback, while an allowed missing one is answered by it. - it.for([false, true])('refuses segments of only dots and spaces (dotfiles: %s)', async (dotfiles, { onTestFinished }) => { - const client = await createRawClient({ dotfiles, fallbackFile: 'hello.txt' }) - onTestFinished(() => client.close()) - - for (const url of ['/..', '/..%20/secret.txt', '/...%20./secret.txt', '/.../secret.txt', '/%20./secret.txt', '/%20../secret.txt', '/%20/secret.txt']) { - expect((await client.get(url)).status, url).toBe(404) - } + it.for([false, true])( + 'refuses segments of only dots and spaces (dotfiles: %s)', + async (dotfiles, { onTestFinished }) => { + const client = await createRawClient({ dotfiles, fallbackFile: 'hello.txt' }) + onTestFinished(() => client.close()) + + for (const url of [ + '/..', + '/..%20/secret.txt', + '/...%20./secret.txt', + '/.../secret.txt', + '/%20./secret.txt', + '/%20../secret.txt', + '/%20/secret.txt', + ]) { + expect((await client.get(url)).status, url).toBe(404) + } - expect((await client.get('/..a/missing.txt')).status).toBe(dotfiles ? 200 : 404) - expect((await client.get('/a%20b/missing.txt')).status).toBe(200) - }) + expect((await client.get('/..a/missing.txt')).status).toBe(dotfiles ? 200 : 404) + expect((await client.get('/a%20b/missing.txt')).status).toBe(200) + }, + ) it('refuses dot segments instead of resolving them', async ({ onTestFinished }) => { const client = await createRawClient({ path: '/assets' }) onTestFinished(() => client.close()) - for (const target of ['/assets/nested/../hello.txt', '/assets/nested/%2e%2e/hello.txt', '/assets/nested/..%5chello.txt', '/assets/../../hello.txt', '/assets/nested/../../secret.txt']) { + for (const target of [ + '/assets/nested/../hello.txt', + '/assets/nested/%2e%2e/hello.txt', + '/assets/nested/..%5chello.txt', + '/assets/../../hello.txt', + '/assets/nested/../../secret.txt', + ]) { const res = await client.get(target) expect(res.status, target).toBe(404) expect(res.body, target).toBe('not matched') @@ -560,7 +633,11 @@ describe('staticFileHandlerPlugin', () => { const client = await createRawClient({ path: '/assets' }) onTestFinished(() => client.close()) - for (const target of ['/assets/./hello.txt', '/assets/%2e/hello.txt', '/assets/nested/./style.css']) { + for (const target of [ + '/assets/./hello.txt', + '/assets/%2e/hello.txt', + '/assets/nested/./style.css', + ]) { expect((await client.get(target)).status, target).toBe(200) } @@ -610,10 +687,14 @@ describe('staticFileHandlerPlugin', () => { it('serves configured index and fallback files even when they are dotfiles', async () => { expect((await createStaticAgent({ indexFile: '.secret' }).get('/')).status).toBe(200) - expect((await createStaticAgent({ fallbackFile: '.secret' }).get('/missing')).status).toBe(200) + expect((await createStaticAgent({ fallbackFile: '.secret' }).get('/missing')).status).toBe( + 200, + ) // Request paths are still blocked - expect((await createStaticAgent({ fallbackFile: 'index.html' }).get('/.secret')).status).toBe(404) + expect((await createStaticAgent({ fallbackFile: 'index.html' }).get('/.secret')).status).toBe( + 404, + ) }) it('refuses encoded separators, so a proxy matching the raw path cannot be bypassed', async () => { @@ -624,10 +705,14 @@ describe('staticFileHandlerPlugin', () => { } }) - it('decodes each segment on its own, so one malformed escape does not affect the others', async ({ onTestFinished }) => { + it('decodes each segment on its own, so one malformed escape does not affect the others', async ({ + onTestFinished, + }) => { mkdirSync(path.join(rootDir, 'caf\u00E9')) writeFileSync(path.join(rootDir, 'caf\u00E9', '50%.txt'), 'percent') - onTestFinished(() => rmSync(path.join(rootDir, 'caf\u00E9'), { recursive: true, force: true })) + onTestFinished(() => + rmSync(path.join(rootDir, 'caf\u00E9'), { recursive: true, force: true }), + ) const res = await createStaticAgent().get('/caf%C3%A9/50%.txt') expect(res.status).toBe(200) @@ -659,7 +744,16 @@ describe('staticFileHandlerPlugin', () => { // A refused path falls through before the fallback, while an allowed missing one is answered by it const agent = createStaticAgent({ fallbackFile: 'hello.txt' }) - for (const url of ['/ENV~1', '/env~1', '/HTACCE~1', '/ENV~1.LOC', '/ENV~1.', '/ENV~1::$DATA', '/GIT~1/config', '/nested/GIT~1/HEAD']) { + for (const url of [ + '/ENV~1', + '/env~1', + '/HTACCE~1', + '/ENV~1.LOC', + '/ENV~1.', + '/ENV~1::$DATA', + '/GIT~1/config', + '/nested/GIT~1/HEAD', + ]) { expect((await agent.get(url)).status, url).toBe(404) } @@ -668,7 +762,10 @@ describe('staticFileHandlerPlugin', () => { expect((await agent.get(url)).status, url).toBe(200) } - expect((await createStaticAgent({ fallbackFile: 'hello.txt', dotfiles: true }).get('/ENV~1')).status).toBe(200) + expect( + (await createStaticAgent({ fallbackFile: 'hello.txt', dotfiles: true }).get('/ENV~1')) + .status, + ).toBe(200) }) it('hides nested dotfiles and dot directories', async () => { @@ -732,12 +829,17 @@ describe('staticFileHandlerPlugin', () => { expect(indexRes.text).toBe('

home

') }) - it.skipIf(process.platform === 'win32')('supports a rootDir that is the filesystem root', async () => { - const res = await createStaticAgent({ rootDir: path.parse(rootDir).root }).get(path.join(rootDir, 'hello.txt')) + it.skipIf(process.platform === 'win32')( + 'supports a rootDir that is the filesystem root', + async () => { + const res = await createStaticAgent({ rootDir: path.parse(rootDir).root }).get( + path.join(rootDir, 'hello.txt'), + ) - expect(res.status).toBe(200) - expect(res.text).toBe('hello world') - }) + expect(res.status).toBe(200) + expect(res.text).toBe('hello world') + }, + ) }) describe('precompressed', () => { @@ -775,9 +877,7 @@ describe('staticFileHandlerPlugin', () => { }) it('is disabled by default', async () => { - const res = await createStaticAgent() - .get('/compressed.txt') - .set('accept-encoding', 'gzip') + const res = await createStaticAgent().get('/compressed.txt').set('accept-encoding', 'gzip') expect(res.headers['content-encoding']).toBeUndefined() expect(res.headers.vary).toBeUndefined() @@ -805,7 +905,9 @@ describe('staticFileHandlerPlugin', () => { const rejectedRes = await agent.get('/compressed.txt').set('accept-encoding', 'br;q=0, *') expect(rejectedRes.headers['content-encoding']).toBe('gzip') - const allRejectedRes = await agent.get('/compressed.txt').set('accept-encoding', '*, br;q=0, gzip;q=0, zstd;q=0') + const allRejectedRes = await agent + .get('/compressed.txt') + .set('accept-encoding', '*, br;q=0, gzip;q=0, zstd;q=0') expect(allRejectedRes.headers['content-encoding']).toBeUndefined() expect(allRejectedRes.text).toBe('identity content') }) @@ -846,7 +948,10 @@ describe('staticFileHandlerPlugin', () => { const agent = createStaticAgent({ precompressed: true }) const etag = (await agent.get('/compressed.txt').set('accept-encoding', 'gzip')).headers.etag! - const res = await agent.get('/compressed.txt').set('accept-encoding', 'gzip').set('if-none-match', etag) + const res = await agent + .get('/compressed.txt') + .set('accept-encoding', 'gzip') + .set('if-none-match', etag) expect(res.status).toBe(304) expect(res.headers['content-encoding']).toBeUndefined() @@ -876,9 +981,17 @@ describe('staticFileHandlerPlugin', () => { }) it('restarts a download resumed through response compression instead of splicing identity bytes into compressed ones', async () => { - const agent = createAgent(new RPCHandler({}, { - plugins: [new StaticFileHandlerPlugin({ rootDir }), new ResponseCompressionHandlerPlugin({ threshold: 0 })], - })) + const agent = createAgent( + new RPCHandler( + {}, + { + plugins: [ + new StaticFileHandlerPlugin({ rootDir }), + new ResponseCompressionHandlerPlugin({ threshold: 0 }), + ], + }, + ), + ) const first = await agent.get('/hello.txt').set('accept-encoding', 'gzip') expect(first.headers['content-encoding']).toBe('gzip') @@ -886,12 +999,19 @@ describe('staticFileHandlerPlugin', () => { expect(first.headers['accept-ranges']).toBeUndefined() // The weak tag cannot satisfy if-range, so the full compressed body comes back instead of a 206 - const resumed = await agent.get('/hello.txt').set('accept-encoding', 'gzip').set('range', 'bytes=5-').set('if-range', first.headers.etag!) + const resumed = await agent + .get('/hello.txt') + .set('accept-encoding', 'gzip') + .set('range', 'bytes=5-') + .set('if-range', first.headers.etag!) expect(resumed.status).toBe(200) expect(resumed.headers['content-encoding']).toBe('gzip') expect(resumed.text).toBe('hello world') - const revalidated = await agent.get('/hello.txt').set('accept-encoding', 'gzip').set('if-none-match', first.headers.etag!) + const revalidated = await agent + .get('/hello.txt') + .set('accept-encoding', 'gzip') + .set('if-none-match', first.headers.etag!) expect(revalidated.status).toBe(304) }) @@ -930,9 +1050,15 @@ describe('staticFileHandlerPlugin', () => { describe('tracing', () => { it('renames the active span to the mounted base path', async ({ onTestFinished }) => { - const span = { updateName: vi.fn(), setAttribute: vi.fn(), recordException: vi.fn(), end: vi.fn() } + const span = { + updateName: vi.fn(), + setAttribute: vi.fn(), + recordException: vi.fn(), + end: vi.fn(), + } const spy = vi.spyOn(sharedModule, 'getTracer').mockReturnValue({ - startActiveSpan: (_name: unknown, _parent: unknown, fn: (span: unknown) => unknown) => fn(span), + startActiveSpan: (_name: unknown, _parent: unknown, fn: (span: unknown) => unknown) => + fn(span), withActiveSpan: (_: unknown, fn: () => unknown) => fn(), getActiveSpan: () => span, } as any) @@ -957,9 +1083,12 @@ describe('staticFileHandlerPlugin', () => { */ describe('fetch adapter', () => { it('serves a file and falls through when no file matches', async () => { - const handler = new FetchRPCHandler({}, { - plugins: [new StaticFileHandlerPlugin({ rootDir })], - }) + const handler = new FetchRPCHandler( + {}, + { + plugins: [new StaticFileHandlerPlugin({ rootDir })], + }, + ) const served = await handler.handle(new Request('https://example.com/hello.txt')) expect(served.matched).toBe(true) diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index 7b79a1d969..09089749f0 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -1,11 +1,25 @@ -import type { Context } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '@orpc/server/standard' -import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@standard-server/core' import type { Stats } from 'node:fs' import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import path from 'node:path' -import { getTracer, isAcceptableEncoding, isCompressibleContentType, matchesHttpPathPrefix, mergeHttpPath, parseAcceptEncodingQualities, safeDecodeURIComponent, toArray } from '@orpc/shared' + +import type { Context } from '@orpc/server' +import type { + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, +} from '@orpc/server/standard' +import { + getTracer, + isAcceptableEncoding, + isCompressibleContentType, + matchesHttpPathPrefix, + mergeHttpPath, + parseAcceptEncodingQualities, + safeDecodeURIComponent, + toArray, +} from '@orpc/shared' +import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@standard-server/core' import { flattenStandardHeader, parseStandardUrl } from '@standard-server/core' import { toWebReadableStream } from '@standard-server/node' import mime from 'mime' @@ -113,29 +127,39 @@ export class StaticFileHandlerPlugin implements StandardHandl this.rootDir = path.resolve(options.rootDir) this.path = stripTrailingSlash(options.path ?? '/') this.indexFile = options.indexFile ?? 'index.html' - this.fallbackFilePath = options.fallbackFile === undefined ? undefined : path.join(this.rootDir, options.fallbackFile) + this.fallbackFilePath = + options.fallbackFile === undefined ? undefined : path.join(this.rootDir, options.fallbackFile) this.cacheControl = options.cacheControl ?? 'public, max-age=0' - this.unsafeSegmentRegex = options.dotfiles ? UNSAFE_SEGMENT_REGEX : UNSAFE_OR_DOTFILE_SEGMENT_REGEX + this.unsafeSegmentRegex = options.dotfiles + ? UNSAFE_SEGMENT_REGEX + : UNSAFE_OR_DOTFILE_SEGMENT_REGEX this.precompressed = options.precompressed ?? false this.mimeTypes = Object.assign(Object.create(null) as Record, options.mimeTypes) } init(options: StandardHandlerOptions): StandardHandlerOptions { - const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ next, request, prefix }) => { + const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ + next, + request, + prefix, + }) => { const result = await next() if (result.matched || (request.method !== 'GET' && request.method !== 'HEAD')) { return result } - const base = prefix === undefined ? this.path : stripTrailingSlash(mergeHttpPath(prefix, this.path)) + const base = + prefix === undefined ? this.path : stripTrailingSlash(mergeHttpPath(prefix, this.path)) const response = await this.serve(request, base) if (response === undefined) { return result } - getTracer()?.getActiveSpan()?.updateName(`${request.method} ${base === '/' ? '' : base}/* (static file)`) + getTracer() + ?.getActiveSpan() + ?.updateName(`${request.method} ${base === '/' ? '' : base}/* (static file)`) return { matched: true, response } } @@ -146,7 +170,10 @@ export class StaticFileHandlerPlugin implements StandardHandl } } - private async serve(request: StandardLazyRequest, base: `/${string}`): Promise { + private async serve( + request: StandardLazyRequest, + base: `/${string}`, + ): Promise { const [pathname] = parseStandardUrl(request.url) if (base !== '/' && !matchesHttpPathPrefix(pathname, base)) { @@ -200,7 +227,9 @@ export class StaticFileHandlerPlugin implements StandardHandl if (this.precompressed && isCompressibleContentType(contentType)) { headers.vary = 'accept-encoding' - const qualities = parseAcceptEncodingQualities(flattenStandardHeader(request.headers['accept-encoding'])) + const qualities = parseAcceptEncodingQualities( + flattenStandardHeader(request.headers['accept-encoding']), + ) for (const [encoding, extension] of PRECOMPRESSED_ENCODINGS) { if (!isAcceptableEncoding(qualities, encoding)) { @@ -238,7 +267,8 @@ export class StaticFileHandlerPlugin implements StandardHandl let start = 0 let end = size - 1 - const range = request.method === 'GET' ? flattenStandardHeader(request.headers.range) : undefined + const range = + request.method === 'GET' ? flattenStandardHeader(request.headers.range) : undefined if (range !== undefined && isRangeApplicable(request.headers, etag, lastModifiedTime)) { const parsed = parseByteRange(range, size) @@ -261,9 +291,12 @@ export class StaticFileHandlerPlugin implements StandardHandl headers['content-type'] = contentType headers['content-length'] = `${end - start + 1}` - const body: ReadableStream> = request.method === 'HEAD' - ? new ReadableStream({ start: controller => controller.close() }) - : toWebReadableStream(createReadStream(filePath, status === 206 ? { start, end } : undefined)) + const body: ReadableStream> = + request.method === 'HEAD' + ? new ReadableStream({ start: (controller) => controller.close() }) + : toWebReadableStream( + createReadStream(filePath, status === 206 ? { start, end } : undefined), + ) return { status, headers, body } } @@ -291,17 +324,24 @@ function getStats(filePath: string): Promise { } function stripTrailingSlash(path: `/${string}`): `/${string}` { - return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) as `/${string}` : path + return path.length > 1 && path.endsWith('/') ? (path.slice(0, -1) as `/${string}`) : path } -function isNotModified(requestHeaders: StandardHeaders, etag: string, lastModifiedTime: number): boolean { +function isNotModified( + requestHeaders: StandardHeaders, + etag: string, + lastModifiedTime: number, +): boolean { const ifNoneMatch = flattenStandardHeader(requestHeaders['if-none-match']) if (ifNoneMatch !== undefined) { - return ifNoneMatch.trim() === '*' || ifNoneMatch.split(',').some((tag) => { - tag = tag.trim() - return (tag.startsWith('W/') ? tag.slice(2) : tag) === etag - }) + return ( + ifNoneMatch.trim() === '*' || + ifNoneMatch.split(',').some((tag) => { + tag = tag.trim() + return (tag.startsWith('W/') ? tag.slice(2) : tag) === etag + }) + ) } const ifModifiedSince = flattenStandardHeader(requestHeaders['if-modified-since']) @@ -309,7 +349,11 @@ function isNotModified(requestHeaders: StandardHeaders, etag: string, lastModifi return ifModifiedSince !== undefined && lastModifiedTime <= Date.parse(ifModifiedSince) } -function isRangeApplicable(requestHeaders: StandardHeaders, etag: string, lastModifiedTime: number): boolean { +function isRangeApplicable( + requestHeaders: StandardHeaders, + etag: string, + lastModifiedTime: number, +): boolean { const ifRange = flattenStandardHeader(requestHeaders['if-range']) if (ifRange === undefined) { @@ -321,7 +365,10 @@ function isRangeApplicable(requestHeaders: StandardHeaders, etag: string, lastMo : Date.parse(ifRange) === lastModifiedTime } -function parseByteRange(range: string, size: number): [start: number, end: number] | 'unsatisfiable' | undefined { +function parseByteRange( + range: string, + size: number, +): [start: number, end: number] | 'unsatisfiable' | undefined { const match = range.match(/^bytes=\s*(\d*)-(\d*)$/i) if (match === null || (match[1] === '' && match[2] === '')) { @@ -330,7 +377,9 @@ function parseByteRange(range: string, size: number): [start: number, end: numbe if (match[1] === '') { const suffixLength = Number(match[2]) - return suffixLength === 0 || size === 0 ? 'unsatisfiable' : [Math.max(0, size - suffixLength), size - 1] + return suffixLength === 0 || size === 0 + ? 'unsatisfiable' + : [Math.max(0, size - suffixLength), size - 1] } const start = Number(match[1]) diff --git a/packages/node/src/tmp-file-upload-handler-plugin.test.ts b/packages/node/src/tmp-file-upload-handler-plugin.test.ts index 58267c2cd8..d588480512 100644 --- a/packages/node/src/tmp-file-upload-handler-plugin.test.ts +++ b/packages/node/src/tmp-file-upload-handler-plugin.test.ts @@ -1,17 +1,19 @@ -import type { Context } from '@orpc/server' -import type { StandardBodyHint, StandardLazyRequest } from '@standard-server/core' -import type { IncomingMessage, ServerResponse } from 'node:http' import { Buffer } from 'node:buffer' import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import type { IncomingMessage, ServerResponse } from 'node:http' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import path from 'node:path' + import { RPCSerializer } from '@orpc/client' +import type { Context } from '@orpc/server' import { ORPCError, os } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' +import type { StandardBodyHint, StandardLazyRequest } from '@standard-server/core' import { generateContentDisposition } from '@standard-server/core' import request from 'supertest' -import { TmpFile, TmpFileUploadHandlerPlugin } from './tmp-file-upload-handler-plugin' + +import { TmpFile, TmpFileUploadHandlerPlugin } from './tmp-file-upload-handler-plugin.ts' describe('tmpFileUploadHandlerPlugin', () => { let tmpDir: string @@ -33,70 +35,85 @@ describe('tmpFileUploadHandlerPlugin', () => { tmpDirEntries: string[] } - function createAgent(options: { - tmpDir?: string - memory?: number - file?: number - stream?: number - plugin?: TmpFileUploadHandlerPlugin - onInput?: (input: any) => unknown - } = {}) { + function createAgent( + options: { + tmpDir?: string + memory?: number + file?: number + stream?: number + plugin?: TmpFileUploadHandlerPlugin + onInput?: (input: any) => unknown + } = {}, + ) { const captured: Captured[] = [] - const handler = new RPCHandler({ - upload: os.handler(async ({ input }) => { - const files: TmpFile[] = [] - const visit = (value: unknown): void => { - if (value instanceof TmpFile) { - files.push(value) + const handler = new RPCHandler( + { + upload: os.handler(async ({ input }) => { + const files: TmpFile[] = [] + const visit = (value: unknown): void => { + if (value instanceof TmpFile) { + files.push(value) + } else if (value !== null && typeof value === 'object' && !(value instanceof Blob)) { + Object.values(value).forEach(visit) + } } - else if (value !== null && typeof value === 'object' && !(value instanceof Blob)) { - Object.values(value).forEach(visit) - } - } - visit(input) - - captured.push({ - input, - filesExisted: files.every(file => existsSync(file.path)), - fileContents: await Promise.all(files.map(async file => Buffer.from(await file.arrayBuffer()))), - tmpDirEntries: readdirSync(tmpDir), - }) + visit(input) + + captured.push({ + input, + filesExisted: files.every((file) => existsSync(file.path)), + fileContents: await Promise.all( + files.map(async (file) => Buffer.from(await file.arrayBuffer())), + ), + tmpDirEntries: readdirSync(tmpDir), + }) - return options.onInput?.(input) ?? { ok: true } - }), - }, { - plugins: [options.plugin ?? new TmpFileUploadHandlerPlugin({ - tmpDir: options.tmpDir ?? tmpDir, - maxBodySize: { - memory: options.memory ?? Number.POSITIVE_INFINITY, - file: options.file ?? Number.POSITIVE_INFINITY, - stream: options.stream ?? Number.POSITIVE_INFINITY, - }, - // Small test bodies still spool - spoolThreshold: 0, - })], - }) + return options.onInput?.(input) ?? { ok: true } + }), + }, + { + plugins: [ + options.plugin ?? + new TmpFileUploadHandlerPlugin({ + tmpDir: options.tmpDir ?? tmpDir, + maxBodySize: { + memory: options.memory ?? Number.POSITIVE_INFINITY, + file: options.file ?? Number.POSITIVE_INFINITY, + stream: options.stream ?? Number.POSITIVE_INFINITY, + }, + // Small test bodies still spool + spoolThreshold: 0, + }), + ], + }, + ) - const agent = request(createServer(async (req: IncomingMessage, res: ServerResponse) => { - const result = await handler.handle(req, res, { context: {} }) + const agent = request( + createServer(async (req: IncomingMessage, res: ServerResponse) => { + const result = await handler.handle(req, res, { context: {} }) - if (!result.matched) { - res.statusCode = 404 - res.end('not matched') - } - })) + if (!result.matched) { + res.statusCode = 404 + res.end('not matched') + } + }), + ) return { agent, captured } } - function toStream(body: Buffer, chunkSize = 16, delivery: 'sync' | 'async' = 'sync'): ReadableStream { + function toStream( + body: Buffer, + chunkSize = 16, + delivery: 'sync' | 'async' = 'sync', + ): ReadableStream { let offset = 0 return new ReadableStream({ async pull(controller) { if (delivery === 'async') { - await new Promise(resolve => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) } if (offset >= body.length) { @@ -104,7 +121,7 @@ describe('tmpFileUploadHandlerPlugin', () => { return } - controller.enqueue(body.subarray(offset, offset += chunkSize)) + controller.enqueue(body.subarray(offset, (offset += chunkSize))) }, }) } @@ -115,7 +132,7 @@ describe('tmpFileUploadHandlerPlugin', () => { * because deferred resources vanish once the interceptor finishes. */ async function runThroughPlugin(options: { - limits?: { memory?: number, file?: number, stream?: number } + limits?: { memory?: number; file?: number; stream?: number } spoolThreshold?: number plugin?: TmpFileUploadHandlerPlugin headers: Record @@ -128,16 +145,18 @@ describe('tmpFileUploadHandlerPlugin', () => { resolveBody?: StandardLazyRequest['resolveBody'] inspect: (resolved: unknown) => void | Promise }): Promise { - const plugin = options.plugin ?? new TmpFileUploadHandlerPlugin({ - tmpDir, - maxBodySize: { - memory: options.limits?.memory ?? Number.POSITIVE_INFINITY, - file: options.limits?.file ?? Number.POSITIVE_INFINITY, - stream: options.limits?.stream ?? Number.POSITIVE_INFINITY, - }, - // Small test bodies still spool - spoolThreshold: options.spoolThreshold ?? 0, - }) + const plugin = + options.plugin ?? + new TmpFileUploadHandlerPlugin({ + tmpDir, + maxBodySize: { + memory: options.limits?.memory ?? Number.POSITIVE_INFINITY, + file: options.limits?.file ?? Number.POSITIVE_INFINITY, + stream: options.limits?.stream ?? Number.POSITIVE_INFINITY, + }, + // Small test bodies still spool + spoolThreshold: options.spoolThreshold ?? 0, + }) const interceptor = plugin.init({}).routingInterceptors![0]! await interceptor({ @@ -147,7 +166,10 @@ describe('tmpFileUploadHandlerPlugin', () => { method: 'POST', url: '/upload', headers: options.headers, - resolveBody: options.resolveBody ?? (async () => toStream(options.body ?? Buffer.alloc(0), options.chunkSize, options.delivery)), + resolveBody: + options.resolveBody ?? + (async () => + toStream(options.body ?? Buffer.alloc(0), options.chunkSize, options.delivery)), }, next: async (nextOptions) => { await options.inspect(await nextOptions!.request.resolveBody(options.hint)) @@ -156,7 +178,11 @@ describe('tmpFileUploadHandlerPlugin', () => { }) } - async function expectFileBody(body: Buffer, spooled: boolean, options: Omit[0], 'headers' | 'body' | 'inspect'> = {}): Promise { + async function expectFileBody( + body: Buffer, + spooled: boolean, + options: Omit[0], 'headers' | 'body' | 'inspect'> = {}, + ): Promise { await runThroughPlugin({ ...options, headers: { 'standard-server': 'file' }, @@ -174,19 +200,27 @@ describe('tmpFileUploadHandlerPlugin', () => { /** * Serializes parts by hand, so each part's exact cost is known. */ - function createMultipartBody(parts: Array<{ header: string, content?: string }>): { body: Buffer, headerSizes: number[], contentSizes: number[] } { - const headerBlocks = parts.map(part => `${part.header}\r\n\r\n`) - const contents = parts.map(part => part.content ?? '') + function createMultipartBody(parts: Array<{ header: string; content?: string }>): { + body: Buffer + headerSizes: number[] + contentSizes: number[] + } { + const headerBlocks = parts.map((part) => `${part.header}\r\n\r\n`) + const contents = parts.map((part) => part.content ?? '') return { - body: Buffer.from(`${parts.map((_, i) => `--${boundary}\r\n${headerBlocks[i]}${contents[i]}\r\n`).join('')}--${boundary}--\r\n`), - headerSizes: headerBlocks.map(block => Buffer.byteLength(block)), - contentSizes: contents.map(content => Buffer.byteLength(content)), + body: Buffer.from( + `${parts.map((_, i) => `--${boundary}\r\n${headerBlocks[i]}${contents[i]}\r\n`).join('')}--${boundary}--\r\n`, + ), + headerSizes: headerBlocks.map((block) => Buffer.byteLength(block)), + contentSizes: contents.map((content) => Buffer.byteLength(content)), } } function listTmpFiles(): string[] { - return readdirSync(tmpDir).flatMap(dir => readdirSync(path.join(tmpDir, dir)).map(file => path.join(tmpDir, dir, file))) + return readdirSync(tmpDir).flatMap((dir) => + readdirSync(path.join(tmpDir, dir)).map((file) => path.join(tmpDir, dir, file)), + ) } it('spools a file body to a tmp file and removes it when the request finishes', async () => { @@ -287,7 +321,7 @@ describe('tmpFileUploadHandlerPlugin', () => { expect(res.status).toBe(200) - const input = captured[0]!.input as { meta: string, file: TmpFile } + const input = captured[0]!.input as { meta: string; file: TmpFile } expect(input.meta).toBe('nested 🚀 value') expect(input.file).toBeInstanceOf(TmpFile) expect(input.file.name).toBe('dữ liệu 🚀.bin') @@ -301,10 +335,11 @@ describe('tmpFileUploadHandlerPlugin', () => { it('keeps tmp files alive while an event stream response reads them', async () => { const { agent } = createAgent({ - onInput: (input: TmpFile) => (async function* () { - yield `first:${await input.text()}` - yield `second:${await input.text()}` - })(), + onInput: (input: TmpFile) => + (async function* () { + yield `first:${await input.text()}` + yield `second:${await input.text()}` + })(), }) const res = await agent @@ -336,7 +371,7 @@ describe('tmpFileUploadHandlerPlugin', () => { .buffer(true) .parse((res, callback) => { const chunks: Buffer[] = [] - res.on('data', chunk => chunks.push(chunk)) + res.on('data', (chunk) => chunks.push(chunk)) res.on('end', () => callback(null, Buffer.concat(chunks))) }) @@ -450,44 +485,54 @@ describe('tmpFileUploadHandlerPlugin', () => { return true } - const tamperAfterSpool = (tamper: (file: string) => void, lastChunk?: Uint8Array) => async () => new ReadableStream({ - start(controller) { - controller.enqueue(Buffer.from('spooled')) - }, - async pull(controller) { - // The stream asks for more before the first chunk has spooled - tamper(await vi.waitFor(() => { - const [file] = listTmpFiles() - - if (file === undefined) { - throw new Error('not spooled yet') + const tamperAfterSpool = (tamper: (file: string) => void, lastChunk?: Uint8Array) => async () => + new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from('spooled')) + }, + async pull(controller) { + // The stream asks for more before the first chunk has spooled + tamper( + await vi.waitFor( + () => { + const [file] = listTmpFiles() + + if (file === undefined) { + throw new Error('not spooled yet') + } + + return file + }, + { interval: 1 }, + ), + ) + + if (lastChunk !== undefined) { + controller.enqueue(lastChunk) } - return file - }, { interval: 1 })) - - if (lastChunk !== undefined) { - controller.enqueue(lastChunk) - } + controller.close() + }, + }) - controller.close() - }, - }) + await expect( + runThroughPlugin({ + headers: { 'standard-server': 'file' }, + resolveBody: tamperAfterSpool((file) => { + rmSync(file) + mkdirSync(file) + }, Buffer.from('unwritable')), + inspect: () => {}, + }), + ).rejects.toSatisfy(expectInternalError) - await expect(runThroughPlugin({ - headers: { 'standard-server': 'file' }, - resolveBody: tamperAfterSpool((file) => { - rmSync(file) - mkdirSync(file) - }, Buffer.from('unwritable')), - inspect: () => {}, - })).rejects.toSatisfy(expectInternalError) - - await expect(runThroughPlugin({ - headers: { 'standard-server': 'file' }, - resolveBody: tamperAfterSpool(file => rmSync(file)), - inspect: () => {}, - })).rejects.toSatisfy(expectInternalError) + await expect( + runThroughPlugin({ + headers: { 'standard-server': 'file' }, + resolveBody: tamperAfterSpool((file) => rmSync(file)), + inspect: () => {}, + }), + ).rejects.toSatisfy(expectInternalError) expect(readdirSync(tmpDir)).toHaveLength(0) }) @@ -517,20 +562,20 @@ describe('tmpFileUploadHandlerPlugin', () => { await runThroughPlugin({ headers: { 'content-type': 'multipart/form-data; boundary=x' }, resolveBody: async () => preParsed, - inspect: resolved => expect(resolved).toBe(preParsed), + inspect: (resolved) => expect(resolved).toBe(preParsed), }) await runThroughPlugin({ limits: { memory: 1024 }, headers: { 'content-type': 'application/json' }, resolveBody: async () => preParsed, - inspect: resolved => expect(resolved).toBe(preParsed), + inspect: (resolved) => expect(resolved).toBe(preParsed), }) await runThroughPlugin({ headers: { 'standard-server': 'file' }, resolveBody: async () => preParsed, - inspect: resolved => expect(resolved).toBe(preParsed), + inspect: (resolved) => expect(resolved).toBe(preParsed), }) expect(readdirSync(tmpDir)).toHaveLength(0) @@ -561,15 +606,26 @@ describe('tmpFileUploadHandlerPlugin', () => { }) describe('multipart parsing parity with the standard parser', () => { - type MaterializedEntry - = | { kind: 'field', name: string, value: string } - | { kind: 'file', name: string, fileName: string, type: string, content: Buffer, isTmpFile: boolean } + type MaterializedEntry = + | { kind: 'field'; name: string; value: string } + | { + kind: 'file' + name: string + fileName: string + type: string + content: Buffer + isTmpFile: boolean + } /** * Materializes the parsed entries while the request scope is still open, * because the backing tmp files vanish once the interceptor finishes. */ - async function parseThroughPlugin(body: Buffer, contentType: string, options: { chunkSize?: number, memory?: number, delivery?: 'sync' | 'async' } = {}): Promise { + async function parseThroughPlugin( + body: Buffer, + contentType: string, + options: { chunkSize?: number; memory?: number; delivery?: 'sync' | 'async' } = {}, + ): Promise { let entries: MaterializedEntry[] await runThroughPlugin({ @@ -579,28 +635,38 @@ describe('tmpFileUploadHandlerPlugin', () => { chunkSize: options.chunkSize, delivery: options.delivery, inspect: async (form) => { - entries = await Promise.all([...form as FormData].map(async ([name, value]) => typeof value === 'string' - ? { kind: 'field' as const, name, value } - : { - kind: 'file' as const, - name, - fileName: value.name, - type: value.type, - content: Buffer.from(await value.arrayBuffer()), - isTmpFile: value instanceof TmpFile, - })) + entries = await Promise.all( + [...(form as FormData)].map(async ([name, value]) => + typeof value === 'string' + ? { kind: 'field' as const, name, value } + : { + kind: 'file' as const, + name, + fileName: value.name, + type: value.type, + content: Buffer.from(await value.arrayBuffer()), + isTmpFile: value instanceof TmpFile, + }, + ), + ) }, }) return entries! } - async function expectMatchesStandardParser(form: FormData, chunkSizes: number[], delivery?: 'sync' | 'async'): Promise { + async function expectMatchesStandardParser( + form: FormData, + chunkSizes: number[], + delivery?: 'sync' | 'async', + ): Promise { const response = new Response(form) const contentType = response.headers.get('content-type')! const body = Buffer.from(await response.arrayBuffer()) - const expectedEntries = [...await new Response(body, { headers: { 'content-type': contentType } }).formData()] + const expectedEntries = [ + ...(await new Response(body, { headers: { 'content-type': contentType } }).formData()), + ] for (const chunkSize of chunkSizes) { const entries = await parseThroughPlugin(body, contentType, { chunkSize, delivery }) @@ -615,10 +681,18 @@ describe('tmpFileUploadHandlerPlugin', () => { if (typeof expectedValue === 'string') { expect(entry).toMatchObject({ kind: 'field', value: expectedValue }) - } - else { - expect(entry).toMatchObject({ kind: 'file', fileName: expectedValue.name, type: expectedValue.type, isTmpFile: expectedValue.size > 0 }) - expect((entry as { content: Buffer }).content.equals(Buffer.from(await expectedValue.arrayBuffer()))).toBe(true) + } else { + expect(entry).toMatchObject({ + kind: 'file', + fileName: expectedValue.name, + type: expectedValue.type, + isTmpFile: expectedValue.size > 0, + }) + expect( + (entry as { content: Buffer }).content.equals( + Buffer.from(await expectedValue.arrayBuffer()), + ), + ).toBe(true) } } } @@ -631,7 +705,12 @@ describe('tmpFileUploadHandlerPlugin', () => { form.append('mixed', 'before the file') form.append('mixed', new File(['file content'], 'tệp tin 🚀.txt', { type: 'text/plain' })) form.append('mixed', 'after the file') - form.append('binary', new File([new Uint8Array([0, 1, 2, 253, 254, 255])], 'raw.bin', { type: 'application/octet-stream' })) + form.append( + 'binary', + new File([new Uint8Array([0, 1, 2, 253, 254, 255])], 'raw.bin', { + type: 'application/octet-stream', + }), + ) form.append('empty', new File([], 'empty.bin')) // Escaped by the serializer as %22, %0D, and %0A, and decoded back by parsers form.append('quo"ted\nname', 'value') @@ -657,20 +736,37 @@ describe('tmpFileUploadHandlerPlugin', () => { */ const form = new FormData() form.append('field', 'value') - form.append('file', new File([new Uint8Array(4096).fill(7)], 'a.bin', { type: 'application/octet-stream' })) + form.append( + 'file', + new File([new Uint8Array(4096).fill(7)], 'a.bin', { type: 'application/octet-stream' }), + ) await expectMatchesStandardParser(form, [1, 2, 3], 'async') }) it('parses randomized forms identically at randomized chunk sizes', async () => { // Deterministic PRNG so a failure reproduces - let seed = 0x2F6E2B1 + let seed = 0x2f6e2b1 const random = (): number => { seed = (seed * 1664525 + 1013904223) % 4294967296 return seed / 4294967296 } - const fragments = ['\r', '\n', '\r\n', '--', '\r\n--', 'formdata-', 'a', '"', ';', 'é', '🚀', '\0', '\r\n--formdata'] + const fragments = [ + '\r', + '\n', + '\r\n', + '--', + '\r\n--', + 'formdata-', + 'a', + '"', + ';', + 'é', + '🚀', + '\0', + '\r\n--formdata', + ] for (let iteration = 0; iteration < 10; iteration++) { const form = new FormData() @@ -685,9 +781,13 @@ describe('tmpFileUploadHandlerPlugin', () => { if (random() < 0.5) { form.append(`field-${i}`, content) - } - else { - form.append(`file-${i}`, new File([content], `file-${i}.bin`, { type: random() < 0.5 ? 'application/octet-stream' : '' })) + } else { + form.append( + `file-${i}`, + new File([content], `file-${i}.bin`, { + type: random() < 0.5 ? 'application/octet-stream' : '', + }), + ) } } @@ -698,74 +798,94 @@ describe('tmpFileUploadHandlerPlugin', () => { it('treats an octet-stream part without a filename as a plain field', async () => { const boundary = 'X-TEST-BOUNDARY' - const body = Buffer.from([ - `--${boundary}`, - 'Content-Disposition: form-data; name="octet"', - 'Content-Type: application/octet-stream', - '', - 'still a field', - `--${boundary}--`, - '', - ].join('\r\n')) + const body = Buffer.from( + [ + `--${boundary}`, + 'Content-Disposition: form-data; name="octet"', + 'Content-Type: application/octet-stream', + '', + 'still a field', + `--${boundary}--`, + '', + ].join('\r\n'), + ) const entries = await parseThroughPlugin(body, `multipart/form-data; boundary=${boundary}`) expect(entries).toEqual([{ kind: 'field', name: 'octet', value: 'still a field' }]) // The field limit applies to it like any other field - await expect(parseThroughPlugin(body, `multipart/form-data; boundary=${boundary}`, { memory: 5 })) - .rejects - .toSatisfy(error => error instanceof ORPCError && error.code === 'PAYLOAD_TOO_LARGE') + await expect( + parseThroughPlugin(body, `multipart/form-data; boundary=${boundary}`, { memory: 5 }), + ).rejects.toSatisfy( + (error) => error instanceof ORPCError && error.code === 'PAYLOAD_TOO_LARGE', + ) expect(readdirSync(tmpDir)).toHaveLength(0) }) it('keeps semicolons and backslashes in quoted content-disposition parameters', async () => { const boundary = 'X-TEST-BOUNDARY' - const body = Buffer.from([ - `--${boundary}`, - 'Content-Disposition: form-data; name="quoted"; filename="fi;\\le .txt"', - '', - 'v', - `--${boundary}--`, - '', - ].join('\r\n')) + const body = Buffer.from( + [ + `--${boundary}`, + 'Content-Disposition: form-data; name="quoted"; filename="fi;\\le .txt"', + '', + 'v', + `--${boundary}--`, + '', + ].join('\r\n'), + ) const contentType = `multipart/form-data; boundary=${boundary}` - const expected = await new Response(body, { headers: { 'content-type': contentType } }).formData() + const expected = await new Response(body, { + headers: { 'content-type': contentType }, + }).formData() const entries = await parseThroughPlugin(body, contentType) - expect(entries).toMatchObject([{ kind: 'file', name: 'quoted', fileName: (expected.get('quoted') as File).name }]) + expect(entries).toMatchObject([ + { kind: 'file', name: 'quoted', fileName: (expected.get('quoted') as File).name }, + ]) expect(entries[0]).toMatchObject({ fileName: 'fi;\\le .txt' }) }) it('parses a quoted boundary parameter, drip-fed through the epilogue', async () => { - const body = Buffer.from([ - '--quoted boundary', - 'Content-Disposition: form-data; name="field"', - '', - 'value', - '--quoted boundary--', - '', - ].join('\r\n')) + const body = Buffer.from( + [ + '--quoted boundary', + 'Content-Disposition: form-data; name="field"', + '', + 'value', + '--quoted boundary--', + '', + ].join('\r\n'), + ) // The boundary parameter may be quoted, with characters a bare token forbids - const entries = await parseThroughPlugin(body, 'multipart/form-data; boundary="quoted boundary"', { chunkSize: 2, delivery: 'async' }) + const entries = await parseThroughPlugin( + body, + 'multipart/form-data; boundary="quoted boundary"', + { chunkSize: 2, delivery: 'async' }, + ) expect(entries).toEqual([{ kind: 'field', name: 'field', value: 'value' }]) }) it('rejects a part whose content-disposition has no name parameter, like the standard parser', async () => { const boundary = 'X-TEST-BOUNDARY' - const body = Buffer.from([ - `--${boundary}`, - 'Content-Disposition: form-data; filename="a.txt"', - '', - 'v', - `--${boundary}--`, - '', - ].join('\r\n')) + const body = Buffer.from( + [ + `--${boundary}`, + 'Content-Disposition: form-data; filename="a.txt"', + '', + 'v', + `--${boundary}--`, + '', + ].join('\r\n'), + ) const contentType = `multipart/form-data; boundary=${boundary}` - await expect(new Response(body, { headers: { 'content-type': contentType } }).formData()).rejects.toThrow() + await expect( + new Response(body, { headers: { 'content-type': contentType } }).formData(), + ).rejects.toThrow() await expect(parseThroughPlugin(body, contentType)).rejects.toThrow('name parameter') expect(readdirSync(tmpDir)).toHaveLength(0) @@ -779,26 +899,32 @@ describe('tmpFileUploadHandlerPlugin', () => { const boundary = 'X-TEST-BOUNDARY' const contentType = `multipart/form-data; boundary=${boundary}` - const withoutDisposition = Buffer.from([ - `--${boundary}`, - 'Content-Type: text/plain', - '', - 'no disposition at all', - `--${boundary}--`, - '', - ].join('\r\n')) - - const wrongDisposition = Buffer.from([ - `--${boundary}`, - 'Content-Disposition: attachment; name="x"', - '', - 'wrong disposition', - `--${boundary}--`, - '', - ].join('\r\n')) + const withoutDisposition = Buffer.from( + [ + `--${boundary}`, + 'Content-Type: text/plain', + '', + 'no disposition at all', + `--${boundary}--`, + '', + ].join('\r\n'), + ) + + const wrongDisposition = Buffer.from( + [ + `--${boundary}`, + 'Content-Disposition: attachment; name="x"', + '', + 'wrong disposition', + `--${boundary}--`, + '', + ].join('\r\n'), + ) for (const body of [withoutDisposition, wrongDisposition]) { - await expect(new Response(body, { headers: { 'content-type': contentType } }).formData()).rejects.toThrow() + await expect( + new Response(body, { headers: { 'content-type': contentType } }).formData(), + ).rejects.toThrow() await expect(parseThroughPlugin(body, contentType)).rejects.toThrow(TypeError) } @@ -808,14 +934,18 @@ describe('tmpFileUploadHandlerPlugin', () => { it('decodes field values as UTF-8 regardless of the part charset, like the standard parser', async () => { const boundary = 'X-TEST-BOUNDARY' const body = Buffer.concat([ - Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="field"\r\nContent-Type: text/plain; charset=latin1\r\n\r\n`), + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="field"\r\nContent-Type: text/plain; charset=latin1\r\n\r\n`, + ), // 0xE9 is é in latin1 and an invalid sequence in UTF-8 - Buffer.from([0xE9]), + Buffer.from([0xe9]), Buffer.from(`\r\n--${boundary}--\r\n`), ]) const contentType = `multipart/form-data; boundary=${boundary}` - const expected = await new Response(body, { headers: { 'content-type': contentType } }).formData() + const expected = await new Response(body, { + headers: { 'content-type': contentType }, + }).formData() const entries = await parseThroughPlugin(body, contentType) expect(entries).toEqual([{ kind: 'field', name: 'field', value: expected.get('field') }]) @@ -824,38 +954,48 @@ describe('tmpFileUploadHandlerPlugin', () => { it('keeps content-type parameters in file types, like the standard parser', async () => { const boundary = 'X-TEST-BOUNDARY' - const body = Buffer.from([ - `--${boundary}`, - 'Content-Disposition: form-data; name="file"; filename="data.csv"', - 'Content-Type: text/csv; charset=utf-8', - '', - 'a,b', - `--${boundary}--`, - '', - ].join('\r\n')) + const body = Buffer.from( + [ + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="data.csv"', + 'Content-Type: text/csv; charset=utf-8', + '', + 'a,b', + `--${boundary}--`, + '', + ].join('\r\n'), + ) const contentType = `multipart/form-data; boundary=${boundary}` - const expected = await new Response(body, { headers: { 'content-type': contentType } }).formData() + const expected = await new Response(body, { + headers: { 'content-type': contentType }, + }).formData() const entries = await parseThroughPlugin(body, contentType) - expect(entries).toMatchObject([{ kind: 'file', fileName: 'data.csv', type: (expected.get('file') as File).type }]) + expect(entries).toMatchObject([ + { kind: 'file', fileName: 'data.csv', type: (expected.get('file') as File).type }, + ]) expect(entries[0]).toMatchObject({ type: 'text/csv; charset=utf-8' }) }) it('rejects a form-data hinted body that is not multipart during parsing', async () => { - await expect(runThroughPlugin({ - headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': '9' }, - body: Buffer.from('key=value'), - chunkSize: 4, - hint: 'form-data', - inspect: () => {}, - })).rejects.toThrow('boundary') + await expect( + runThroughPlugin({ + headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': '9' }, + body: Buffer.from('key=value'), + chunkSize: 4, + hint: 'form-data', + inspect: () => {}, + }), + ).rejects.toThrow('boundary') - await expect(runThroughPlugin({ - headers: {}, - hint: 'form-data', - inspect: () => {}, - })).rejects.toThrow('boundary') + await expect( + runThroughPlugin({ + headers: {}, + hint: 'form-data', + inspect: () => {}, + }), + ).rejects.toThrow('boundary') expect(readdirSync(tmpDir)).toHaveLength(0) }) @@ -868,7 +1008,10 @@ describe('tmpFileUploadHandlerPlugin', () => { const form = new FormData() for (let i = 0; i < 300; i++) { - form.append(`file-${i}`, new File([`content-${i}`], `f-${i}.bin`, { type: 'application/octet-stream' })) + form.append( + `file-${i}`, + new File([`content-${i}`], `f-${i}.bin`, { type: 'application/octet-stream' }), + ) } const response = new Response(form) @@ -889,10 +1032,9 @@ describe('tmpFileUploadHandlerPlugin', () => { body, chunkSize: 256, delivery: 'async', - inspect: parsed => expect([...parsed as FormData]).toHaveLength(300), + inspect: (parsed) => expect([...(parsed as FormData)]).toHaveLength(300), }) - } - finally { + } finally { clearInterval(sampler) } @@ -909,7 +1051,10 @@ describe('tmpFileUploadHandlerPlugin', () => { } const eventStreamHeaders = { 'content-type': 'text/event-stream' } - const octetStreamHeaders = { 'content-type': 'application/octet-stream', 'standard-server': 'octet-stream' } + const octetStreamHeaders = { + 'content-type': 'application/octet-stream', + 'standard-server': 'octet-stream', + } function sum(values: number[]): number { return values.reduce((total, value) => total + value, 0) @@ -929,7 +1074,10 @@ describe('tmpFileUploadHandlerPlugin', () => { /** * Counts the tmp files a rejected request created, before cleanup removes them. */ - async function countTmpFilesWhenRejected(limits: { memory?: number, file?: number }, body: Buffer): Promise { + async function countTmpFilesWhenRejected( + limits: { memory?: number; file?: number }, + body: Buffer, + ): Promise { const plugin = new TmpFileUploadHandlerPlugin({ tmpDir, maxBodySize: { @@ -976,12 +1124,14 @@ describe('tmpFileUploadHandlerPlugin', () => { expect(captured).toHaveLength(0) // Without a declared length the limit trips while streaming - await expect(runThroughPlugin({ - limits: { memory: 10 }, - headers: { 'content-type': 'application/json' }, - body: Buffer.from(JSON.stringify({ padding: 'x'.repeat(64) })), - inspect: () => {}, - })).rejects.toSatisfy(expectPayloadTooLarge) + await expect( + runThroughPlugin({ + limits: { memory: 10 }, + headers: { 'content-type': 'application/json' }, + body: Buffer.from(JSON.stringify({ padding: 'x'.repeat(64) })), + inspect: () => {}, + }), + ).rejects.toSatisfy(expectPayloadTooLarge) // Under the limit the body parses normally await runThroughPlugin({ @@ -995,12 +1145,14 @@ describe('tmpFileUploadHandlerPlugin', () => { }) it('limits url-encoded bodies against maxBodySize.memory', async () => { - await expect(runThroughPlugin({ - limits: { memory: 10 }, - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: Buffer.from(`key=${'v'.repeat(64)}`), - inspect: () => {}, - })).rejects.toSatisfy(expectPayloadTooLarge) + await expect( + runThroughPlugin({ + limits: { memory: 10 }, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: Buffer.from(`key=${'v'.repeat(64)}`), + inspect: () => {}, + }), + ).rejects.toSatisfy(expectPayloadTooLarge) await runThroughPlugin({ limits: { memory: 1024 }, @@ -1027,12 +1179,14 @@ describe('tmpFileUploadHandlerPlugin', () => { expect(readdirSync(tmpDir)).toHaveLength(0) // Without a declared length the limit trips mid-spool and the partial file is removed - await expect(runThroughPlugin({ - limits: { file: 64 }, - headers: { 'content-type': 'application/octet-stream', 'standard-server': 'file' }, - body: Buffer.alloc(4096, 7), - inspect: () => {}, - })).rejects.toSatisfy(expectPayloadTooLarge) + await expect( + runThroughPlugin({ + limits: { file: 64 }, + headers: { 'content-type': 'application/octet-stream', 'standard-server': 'file' }, + body: Buffer.alloc(4096, 7), + inspect: () => {}, + }), + ).rejects.toSatisfy(expectPayloadTooLarge) expect(readdirSync(tmpDir)).toHaveLength(0) }) @@ -1040,9 +1194,15 @@ describe('tmpFileUploadHandlerPlugin', () => { it('limits multipart fields and files against their own categories', async () => { const { body, headerSizes, contentSizes } = createMultipartBody([ { header: 'Content-Disposition: form-data; name="first"', content: 'x'.repeat(6) }, - { header: 'Content-Disposition: form-data; name="upload-1"; filename="a.bin"', content: 'z'.repeat(6) }, + { + header: 'Content-Disposition: form-data; name="upload-1"; filename="a.bin"', + content: 'z'.repeat(6), + }, { header: 'Content-Disposition: form-data; name="second"', content: 'y'.repeat(6) }, - { header: 'Content-Disposition: form-data; name="upload-2"; filename="b.bin"', content: 'w'.repeat(6) }, + { + header: 'Content-Disposition: form-data; name="upload-2"; filename="b.bin"', + content: 'w'.repeat(6), + }, ]) const memoryCost = sum(headerSizes) + contentSizes[0]! + contentSizes[2]! const fileCost = contentSizes[1]! + contentSizes[3]! @@ -1059,12 +1219,14 @@ describe('tmpFileUploadHandlerPlugin', () => { } for (const limits of [{ memory: memoryCost - 1 }, { file: fileCost - 1 }]) { - await expect(runThroughPlugin({ - limits, - headers: multipartHeaders, - body, - inspect: () => {}, - })).rejects.toSatisfy(expectPayloadTooLarge) + await expect( + runThroughPlugin({ + limits, + headers: multipartHeaders, + body, + inspect: () => {}, + }), + ).rejects.toSatisfy(expectPayloadTooLarge) } expect(readdirSync(tmpDir)).toHaveLength(0) @@ -1072,9 +1234,11 @@ describe('tmpFileUploadHandlerPlugin', () => { it('charges file part headers against maxBodySize.memory, and keeps empty file parts off disk', async () => { // The smallest possible file part - const { body, headerSizes } = createMultipartBody(Array.from({ length: 64 }, () => ({ - header: 'Content-Disposition: form-data; name=""; filename=""', - }))) + const { body, headerSizes } = createMultipartBody( + Array.from({ length: 64 }, () => ({ + header: 'Content-Disposition: form-data; name=""; filename=""', + })), + ) const headerCost = sum(headerSizes) await runThroughPlugin({ @@ -1085,7 +1249,12 @@ describe('tmpFileUploadHandlerPlugin', () => { const entries = [...(parsed as FormData)] expect(entries).toHaveLength(64) - expect(entries.every(([, value]) => value instanceof File && !(value instanceof TmpFile) && value.size === 0)).toBe(true) + expect( + entries.every( + ([, value]) => + value instanceof File && !(value instanceof TmpFile) && value.size === 0, + ), + ).toBe(true) }, }) @@ -1094,8 +1263,14 @@ describe('tmpFileUploadHandlerPlugin', () => { it('holds small uploads against maxBodySize.memory, spooling those that no longer fit', async () => { const { body, headerSizes } = createMultipartBody([ - { header: 'Content-Disposition: form-data; name="first"; filename="a.bin"', content: 'a'.repeat(100) }, - { header: 'Content-Disposition: form-data; name="second"; filename="b.bin"', content: 'b'.repeat(100) }, + { + header: 'Content-Disposition: form-data; name="first"; filename="a.bin"', + content: 'a'.repeat(100), + }, + { + header: 'Content-Disposition: form-data; name="second"; filename="b.bin"', + content: 'b'.repeat(100), + }, { header: 'Content-Disposition: form-data; name="field"', content: 'f'.repeat(40) }, ]) @@ -1115,32 +1290,45 @@ describe('tmpFileUploadHandlerPlugin', () => { }, }) - await expectFileBody(Buffer.alloc(100, 7), false, { limits: { memory: 100 }, spoolThreshold: 1024 }) - await expectFileBody(Buffer.alloc(100, 7), true, { limits: { memory: 99 }, spoolThreshold: 1024 }) + await expectFileBody(Buffer.alloc(100, 7), false, { + limits: { memory: 100 }, + spoolThreshold: 1024, + }) + await expectFileBody(Buffer.alloc(100, 7), true, { + limits: { memory: 99 }, + spoolThreshold: 1024, + }) expect(readdirSync(tmpDir)).toHaveLength(0) }) it('charges uploads against maxBodySize.file even while they stay in memory', async () => { const { body } = createMultipartBody([ - { header: 'Content-Disposition: form-data; name="upload"; filename="a.bin"', content: 'z'.repeat(100) }, + { + header: 'Content-Disposition: form-data; name="upload"; filename="a.bin"', + content: 'z'.repeat(100), + }, ]) - await expect(runThroughPlugin({ - limits: { file: 99 }, - spoolThreshold: 1024, - headers: multipartHeaders, - body, - inspect: () => {}, - })).rejects.toSatisfy(expectPayloadTooLarge) + await expect( + runThroughPlugin({ + limits: { file: 99 }, + spoolThreshold: 1024, + headers: multipartHeaders, + body, + inspect: () => {}, + }), + ).rejects.toSatisfy(expectPayloadTooLarge) - await expect(runThroughPlugin({ - limits: { file: 99 }, - spoolThreshold: 1024, - headers: { 'standard-server': 'file' }, - body: Buffer.alloc(100, 7), - inspect: () => {}, - })).rejects.toSatisfy(expectPayloadTooLarge) + await expect( + runThroughPlugin({ + limits: { file: 99 }, + spoolThreshold: 1024, + headers: { 'standard-server': 'file' }, + body: Buffer.alloc(100, 7), + inspect: () => {}, + }), + ).rejects.toSatisfy(expectPayloadTooLarge) expect(readdirSync(tmpDir)).toHaveLength(0) }) @@ -1158,21 +1346,30 @@ describe('tmpFileUploadHandlerPlugin', () => { headers: multipartHeaders, body, inspect: (parsed) => { - expect([...(parsed as FormData)]).toEqual([[name, ''], [name, '']]) + expect([...(parsed as FormData)]).toEqual([ + [name, ''], + [name, ''], + ]) }, }) - await expect(runThroughPlugin({ - limits: { memory: memoryCost - 1 }, - headers: multipartHeaders, - body, - inspect: () => {}, - })).rejects.toSatisfy(expectPayloadTooLarge) + await expect( + runThroughPlugin({ + limits: { memory: memoryCost - 1 }, + headers: multipartHeaders, + body, + inspect: () => {}, + }), + ).rejects.toSatisfy(expectPayloadTooLarge) }) it('limits the whole multipart body to the sum of the memory and file limits', async () => { const content = 'y'.repeat(1024) - const { body: parts, headerSizes, contentSizes } = createMultipartBody([ + const { + body: parts, + headerSizes, + contentSizes, + } = createMultipartBody([ { header: 'Content-Disposition: form-data; name="a"', content: 'x' }, { header: 'Content-Disposition: form-data; name="b"; filename="a.bin"', content }, ]) @@ -1223,7 +1420,9 @@ describe('tmpFileUploadHandlerPlugin', () => { }) it('limits event streams against maxBodySize.stream while they are consumed', async () => { - const events = Buffer.from('event: message\ndata: "one"\n\nevent: message\ndata: "two"\n\nevent: close\n\n') + const events = Buffer.from( + 'event: message\ndata: "one"\n\nevent: message\ndata: "two"\n\nevent: close\n\n', + ) await runThroughPlugin({ limits: { stream: 1024 }, @@ -1298,7 +1497,10 @@ describe('tmpFileUploadHandlerPlugin', () => { it('spools a file body identified only by its content-disposition, per the 0.8 resolution order', async () => { // No content-length and no standard-server header, only a content-disposition await runThroughPlugin({ - headers: { 'content-type': 'application/pdf', 'content-disposition': 'attachment; filename="doc.pdf"' }, + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="doc.pdf"', + }, body: Buffer.from('pdf bytes'), inspect: async (body) => { expect(body).toBeInstanceOf(TmpFile) @@ -1317,7 +1519,9 @@ describe('tmpFileUploadHandlerPlugin', () => { const body = Buffer.from(await response.arrayBuffer()) await runThroughPlugin({ - headers: { 'content-type': contentType.replace('multipart/form-data', 'Multipart/Form-Data') }, + headers: { + 'content-type': contentType.replace('multipart/form-data', 'Multipart/Form-Data'), + }, body, inspect: (parsed) => { expect(parsed).toBeInstanceOf(FormData) @@ -1328,7 +1532,9 @@ describe('tmpFileUploadHandlerPlugin', () => { }) describe('per-request size limits', () => { - interface AuthContext { user?: string } + interface AuthContext { + user?: string + } /** * Runs a request through a plugin whose limits follow the request context: @@ -1342,8 +1548,11 @@ describe('tmpFileUploadHandlerPlugin', () => { resolutions?: number /** Runs while the request scope is still open, before tmp files are removed. */ inspect?: (resolved: unknown[]) => void | Promise - }): Promise<{ resolverCalls: Array<{ context: AuthContext, url: string }>, resolved: unknown[] }> { - const resolverCalls: Array<{ context: AuthContext, url: string }> = [] + }): Promise<{ + resolverCalls: Array<{ context: AuthContext; url: string }> + resolved: unknown[] + }> { + const resolverCalls: Array<{ context: AuthContext; url: string }> = [] const plugin = new TmpFileUploadHandlerPlugin({ tmpDir, @@ -1412,18 +1621,23 @@ describe('tmpFileUploadHandlerPlugin', () => { }, }) - await expect(runWithContextualLimits({ context: {}, headers, body })).rejects.toSatisfy((error) => { - expect(error).toBeInstanceOf(ORPCError) - expect((error as ORPCError).code).toBe('PAYLOAD_TOO_LARGE') - return true - }) + await expect(runWithContextualLimits({ context: {}, headers, body })).rejects.toSatisfy( + (error) => { + expect(error).toBeInstanceOf(ORPCError) + expect((error as ORPCError).code).toBe('PAYLOAD_TOO_LARGE') + return true + }, + ) expect(readdirSync(tmpDir)).toHaveLength(0) }) it('skips the resolver when no body is parsed', async () => { // The routed request never resolves its body - const untouched = await runWithContextualLimits({ context: { user: 'admin' }, resolutions: 0 }) + const untouched = await runWithContextualLimits({ + context: { user: 'admin' }, + resolutions: 0, + }) expect(untouched.resolverCalls).toHaveLength(0) @@ -1448,9 +1662,11 @@ describe('tmpFileUploadHandlerPlugin', () => { }, }) - const { body } = createMultipartBody(Array.from({ length: 5000 }, () => ({ - header: 'Content-Disposition: form-data; name="a"; filename=""', - }))) + const { body } = createMultipartBody( + Array.from({ length: 5000 }, () => ({ + header: 'Content-Disposition: form-data; name="a"; filename=""', + })), + ) const res = await agent.post('/upload').set(multipartHeaders).send(body) diff --git a/packages/node/src/tmp-file-upload-handler-plugin.ts b/packages/node/src/tmp-file-upload-handler-plugin.ts index d92b5e0347..650a803105 100644 --- a/packages/node/src/tmp-file-upload-handler-plugin.ts +++ b/packages/node/src/tmp-file-upload-handler-plugin.ts @@ -1,18 +1,41 @@ -import type { Context } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions } from '@orpc/server/standard' -import type { Promisable, Value } from '@orpc/shared' -import type { StandardBody, StandardBodyHint, StandardHeaders, StandardLazyRequest } from '@standard-server/core' import type { FilePropertyBag } from 'node:buffer' import { Buffer } from 'node:buffer' import { openAsBlob } from 'node:fs' import { appendFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' + +import type { Context } from '@orpc/server' import { ORPCError } from '@orpc/server' -import { isAsyncIteratorObject, override, toArray, value, wrapAsyncIterator, wrapReadableStream } from '@orpc/shared' -import { flattenStandardHeader, getFilenameFromContentDisposition, resolveStandardBodyHint } from '@standard-server/core' +import type { + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, + StandardHandlerRoutingInterceptorOptions, +} from '@orpc/server/standard' +import type { Promisable, Value } from '@orpc/shared' +import { + isAsyncIteratorObject, + override, + toArray, + value, + wrapAsyncIterator, + wrapReadableStream, +} from '@orpc/shared' +import type { + StandardBody, + StandardBodyHint, + StandardHeaders, + StandardLazyRequest, +} from '@standard-server/core' +import { + flattenStandardHeader, + getFilenameFromContentDisposition, + resolveStandardBodyHint, +} from '@standard-server/core' import { toFetchHeaders, toStandardBody } from '@standard-server/fetch' -import { parseHeaderParameters, parseMultipart } from './multipart' + +import { parseHeaderParameters, parseMultipart } from './multipart.ts' export interface TmpFileUploadHandlerPluginMaxBodySize { /** @@ -67,7 +90,12 @@ export interface TmpFileUploadHandlerPluginOptions { * * @default unlimited for every kind */ - maxBodySize?: Value, [options: StandardHandlerRoutingInterceptorOptions]> | undefined + maxBodySize?: + | Value< + Promisable, + [options: StandardHandlerRoutingInterceptorOptions] + > + | undefined /** * The size in bytes an uploaded file must exceed before it is spooled to a @@ -146,7 +174,10 @@ export class TmpFileUploadHandlerPlugin implements StandardHa before: string[] = ['~request-limit', '~request-compression'] private readonly tmpDir: string - private readonly maxBodySize: Exclude['maxBodySize'], undefined> + private readonly maxBodySize: Exclude< + TmpFileUploadHandlerPluginOptions['maxBodySize'], + undefined + > private readonly spoolThreshold: number constructor(options: TmpFileUploadHandlerPluginOptions = {}) { @@ -160,7 +191,10 @@ export class TmpFileUploadHandlerPlugin implements StandardHa } init(options: StandardHandlerOptions): StandardHandlerOptions { - const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ next, ...interceptorOptions }) => { + const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ + next, + ...interceptorOptions + }) => { const tmpFiles = new RequestTmpFiles(this.tmpDir) let cleanupDeferred = false @@ -169,7 +203,7 @@ export class TmpFileUploadHandlerPlugin implements StandardHa ...interceptorOptions, request: { ...interceptorOptions.request, - resolveBody: hint => this.resolveBody(interceptorOptions, hint, tmpFiles), + resolveBody: (hint) => this.resolveBody(interceptorOptions, hint, tmpFiles), }, }) @@ -194,7 +228,10 @@ export class TmpFileUploadHandlerPlugin implements StandardHa * @warning * Remember use `override` for AsyncIteratorObject to remain other special properties */ - body: override(body, wrapAsyncIterator(body, { onFinish: () => tmpFiles.cleanup() })), + body: override( + body, + wrapAsyncIterator(body, { onFinish: () => tmpFiles.cleanup() }), + ), }, } } @@ -210,15 +247,17 @@ export class TmpFileUploadHandlerPlugin implements StandardHa * @warning * Remember use `override` for ReadableStream to remain other special properties */ - body: override(body, wrapReadableStream(body, { onFinish: () => tmpFiles.cleanup() })), + body: override( + body, + wrapReadableStream(body, { onFinish: () => tmpFiles.cleanup() }), + ), }, } } } return result - } - finally { + } finally { if (!cleanupDeferred) { await tmpFiles.cleanup() } @@ -267,7 +306,8 @@ export class TmpFileUploadHandlerPlugin implements StandardHa } // Streams are consumed on the fly, but the decoder buffers each event whole, however fast the stream is read - const eventLimit = resolvedHint === 'event-stream' ? maxBodySize.memory : Number.POSITIVE_INFINITY + const eventLimit = + resolvedHint === 'event-stream' ? maxBodySize.memory : Number.POSITIVE_INFINITY return this.parseLimitedBody(request, hint, resolvedHint, maxBodySize.stream, eventLimit) } @@ -305,7 +345,11 @@ export class TmpFileUploadHandlerPlugin implements StandardHa return toStandardBody(response, { hint: resolvedHint }) } - private async parseFileBody(request: StandardLazyRequest, tmpFiles: RequestTmpFiles, maxBodySize: TmpFileUploadHandlerPluginMaxBodySize): Promise { + private async parseFileBody( + request: StandardLazyRequest, + tmpFiles: RequestTmpFiles, + maxBodySize: TmpFileUploadHandlerPluginMaxBodySize, + ): Promise { assertContentLengthWithin(request.headers, maxBodySize.file) const stream = await request.resolveBody('octet-stream') @@ -316,10 +360,18 @@ export class TmpFileUploadHandlerPlugin implements StandardHa } const contentDisposition = flattenStandardHeader(request.headers['content-disposition']) - const fileName = contentDisposition !== undefined ? getFilenameFromContentDisposition(contentDisposition) : undefined + const fileName = + contentDisposition !== undefined + ? getFilenameFromContentDisposition(contentDisposition) + : undefined const contentType = flattenStandardHeader(request.headers['content-type']) - const upload = new UploadWriter(tmpFiles, new Budget(maxBodySize.memory), new Budget(maxBodySize.file), this.spoolThreshold) + const upload = new UploadWriter( + tmpFiles, + new Budget(maxBodySize.memory), + new Budget(maxBodySize.file), + this.spoolThreshold, + ) for await (const chunk of stream) { await upload.write(chunk) @@ -328,12 +380,19 @@ export class TmpFileUploadHandlerPlugin implements StandardHa return upload.end(fileName ?? 'blob', contentType ?? '') } - private async parseMultipartBody(request: StandardLazyRequest, tmpFiles: RequestTmpFiles, maxBodySize: TmpFileUploadHandlerPluginMaxBodySize): Promise { + private async parseMultipartBody( + request: StandardLazyRequest, + tmpFiles: RequestTmpFiles, + maxBodySize: TmpFileUploadHandlerPluginMaxBodySize, + ): Promise { const contentType = flattenStandardHeader(request.headers['content-type']) - const boundary = contentType === undefined ? undefined : parseHeaderParameters(contentType).get('boundary') + const boundary = + contentType === undefined ? undefined : parseHeaderParameters(contentType).get('boundary') if (boundary === undefined || boundary === '') { - throw new TypeError('Invalid multipart body: content-type header is missing the boundary parameter') + throw new TypeError( + 'Invalid multipart body: content-type header is missing the boundary parameter', + ) } /** @@ -351,7 +410,8 @@ export class TmpFileUploadHandlerPlugin implements StandardHa return stream } - const limited = totalLimit === Number.POSITIVE_INFINITY ? stream : limitStream(stream, totalLimit) + const limited = + totalLimit === Number.POSITIVE_INFINITY ? stream : limitStream(stream, totalLimit) const form = new FormData() const memory = new Budget(maxBodySize.memory) @@ -384,7 +444,7 @@ export class TmpFileUploadHandlerPlugin implements StandardHa const upload = new UploadWriter(tmpFiles, memory, file, this.spoolThreshold) return { - write: chunk => upload.write(chunk), + write: (chunk) => upload.write(chunk), end: async () => { form.append(name, await upload.end(filename, type)) }, @@ -418,22 +478,24 @@ function limitStream( ): ReadableStream { let total = 0 - return stream.pipeThrough(new TransformStream({ - transform(chunk, controller) { - total += chunk.byteLength + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + total += chunk.byteLength - if (total > limit || !fits(chunk)) { - controller.error(new ORPCError('PAYLOAD_TOO_LARGE')) - return - } + if (total > limit || !fits(chunk)) { + controller.error(new ORPCError('PAYLOAD_TOO_LARGE')) + return + } - controller.enqueue(chunk) - }, - })) + controller.enqueue(chunk) + }, + }), + ) } -const CR = 0x0D -const LF = 0x0A +const CR = 0x0d +const LF = 0x0a /** * Counts each event's bytes across chunks until its blank line, since the @@ -469,8 +531,7 @@ function createEventSizeCheck(eventLimit: number): (chunk: Uint8Array) => boolea if (byte === CR) { nextCR = indexOrEnd(bytes, CR, offset) - } - else { + } else { nextLF = indexOrEnd(bytes, LF, offset) } @@ -487,8 +548,7 @@ function createEventSizeCheck(eventLimit: number): (chunk: Uint8Array) => boolea // A blank line: the decoder releases the event eventSize = 0 trailing = 'none' - } - else { + } else { trailing = byte === CR ? 'cr' : 'line' } } @@ -544,7 +604,10 @@ class UploadWriter { return this.tmpFiles.append(this.tmpPath, chunk) } - if (this.heldSize + chunk.length <= this.spoolThreshold && this.memory.tryCharge(chunk.length)) { + if ( + this.heldSize + chunk.length <= this.spoolThreshold && + this.memory.tryCharge(chunk.length) + ) { // Chunks are only guaranteed until write returns, so holding one requires a copy this.held.push(Buffer.from(chunk)) this.heldSize += chunk.length @@ -594,8 +657,7 @@ class RequestTmpFiles { const tmpPath = path.join(await this.dir, String(this.count++)) await appendFile(tmpPath, content) return tmpPath - } - catch (cause) { + } catch (cause) { throw new ORPCError('INTERNAL_SERVER_ERROR', { cause }) } } @@ -603,8 +665,7 @@ class RequestTmpFiles { async append(tmpPath: string, chunk: Uint8Array): Promise { try { await appendFile(tmpPath, chunk) - } - catch (cause) { + } catch (cause) { throw new ORPCError('INTERNAL_SERVER_ERROR', { cause }) } } @@ -612,8 +673,7 @@ class RequestTmpFiles { async seal(tmpPath: string, name: string, type: string): Promise { try { return new TmpFile(await openAsBlob(tmpPath), tmpPath, name, { type }) - } - catch (cause) { + } catch (cause) { throw new ORPCError('INTERNAL_SERVER_ERROR', { cause }) } } @@ -622,8 +682,7 @@ class RequestTmpFiles { if (this.dir !== undefined) { try { await rm(await this.dir, { recursive: true, force: true }) - } - catch { + } catch { // The request outcome must not change because removal failed } } diff --git a/packages/node/tests/serves-static-files.test.ts b/packages/node/tests/serves-static-files.test.ts index 497b3022b2..5d73385f0f 100644 --- a/packages/node/tests/serves-static-files.test.ts +++ b/packages/node/tests/serves-static-files.test.ts @@ -1,11 +1,13 @@ -import type { AddressInfo } from 'node:net' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import path from 'node:path' + import { os } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' -import { StaticFileHandlerPlugin } from '../src' + +import { StaticFileHandlerPlugin } from '../src/index.ts' it('serves static files', async ({ onTestFinished }) => { const rootDir = mkdtempSync(path.join(tmpdir(), 'orpc-node-e2e-')) @@ -15,14 +17,15 @@ it('serves static files', async ({ onTestFinished }) => { writeFileSync(path.join(rootDir, 'hello.txt'), 'hello world') - const handler = new RPCHandler({ - ping: os.handler(() => 'pong'), - }, { - allowMethods: ['GET'], - plugins: [ - new StaticFileHandlerPlugin({ rootDir }), - ], - }) + const handler = new RPCHandler( + { + ping: os.handler(() => 'pong'), + }, + { + allowMethods: ['GET'], + plugins: [new StaticFileHandlerPlugin({ rootDir })], + }, + ) const server = createServer(async (req, res) => { const result = await handler.handle(req, res, { context: {} }) @@ -36,7 +39,7 @@ it('serves static files', async ({ onTestFinished }) => { server.close() }) - await new Promise(resolve => server.listen(0, resolve)) + await new Promise((resolve) => server.listen(0, resolve)) const url = `http://localhost:${(server.address() as AddressInfo).port}` const fileRes = await fetch(`${url}/hello.txt`) @@ -56,7 +59,9 @@ it('serves static files', async ({ onTestFinished }) => { expect(cachedRes.status).toBe(304) expect(await cachedRes.text()).toBe('') - const procedureRes = await fetch(`${url}/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) + const procedureRes = await fetch( + `${url}/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + ) expect(procedureRes.status).toBe(200) expect(await procedureRes.text()).toContain('pong') diff --git a/packages/node/tests/uploads-large-files.test.ts b/packages/node/tests/uploads-large-files.test.ts index 342c1b0815..a975c5afc8 100644 --- a/packages/node/tests/uploads-large-files.test.ts +++ b/packages/node/tests/uploads-large-files.test.ts @@ -1,20 +1,22 @@ -import type { RouterClient } from '@orpc/server' -import type { AddressInfo } from 'node:net' import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs' import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' import { connect } from 'node:net' import { tmpdir } from 'node:os' import path from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' + import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' import { RequestCompressionLinkPlugin } from '@orpc/client/plugins' +import type { RouterClient } from '@orpc/server' import { os, type } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' import { RequestCompressionHandlerPlugin, RequestLimitHandlerPlugin } from '@orpc/server/plugins' -import { TmpFile, TmpFileUploadHandlerPlugin } from '../src' + +import { TmpFile, TmpFileUploadHandlerPlugin } from '../src/index.ts' /** * Driven through a real http server and `RPCLink`, so uploads take the exact @@ -40,9 +42,12 @@ describe('uploads large files through tmp files', () => { type: file.type, size: file.size, // Read from disk while the request is still running, in constant memory - sha256: createHash('sha256').update(new Uint8Array(await file.arrayBuffer())).digest('hex'), + sha256: createHash('sha256') + .update(new Uint8Array(await file.arrayBuffer())) + .digest('hex'), spooledSize: file instanceof TmpFile ? statSync(file.path).size : undefined, - spooledInsideTmpDir: file instanceof TmpFile && path.dirname(path.dirname(file.path)) === tmpDir, + spooledInsideTmpDir: + file instanceof TmpFile && path.dirname(path.dirname(file.path)) === tmpDir, } }), } @@ -67,7 +72,7 @@ describe('uploads large files through tmp files', () => { let url: string beforeAll(async () => { - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) url = `http://127.0.0.1:${(server.address() as AddressInfo).port}` }) @@ -76,7 +81,9 @@ describe('uploads large files through tmp files', () => { rmSync(tmpDir, { recursive: true, force: true }) }) - function createClient(linkPlugins: ConstructorParameters[0]['plugins'] = []): RouterClient { + function createClient( + linkPlugins: ConstructorParameters[0]['plugins'] = [], + ): RouterClient { return createORPCClient(new RPCLink({ url: '/', origin: url, plugins: linkPlugins })) } @@ -149,13 +156,21 @@ describe('uploads large files through tmp files', () => { it('runs concurrent uploads in isolated directories and removes them all', async () => { const client = createClient() - const outputs = await Promise.all(Array.from({ length: 5 }, (_, i) => { - const content = new Uint8Array(512 * 1024 + i).fill(i) - return client.upload({ file: new File([content], `file-${i}.bin`, { type: 'application/octet-stream' }) }) - })) + const outputs = await Promise.all( + Array.from({ length: 5 }, (_, i) => { + const content = new Uint8Array(512 * 1024 + i).fill(i) + return client.upload({ + file: new File([content], `file-${i}.bin`, { type: 'application/octet-stream' }), + }) + }), + ) for (let i = 0; i < outputs.length; i++) { - expect(outputs[i]).toMatchObject({ isTmpFile: true, size: 512 * 1024 + i, spooledInsideTmpDir: true }) + expect(outputs[i]).toMatchObject({ + isTmpFile: true, + size: 512 * 1024 + i, + spooledInsideTmpDir: true, + }) } expect(readdirSync(tmpDir)).toHaveLength(0) @@ -177,13 +192,15 @@ describe('uploads large files through tmp files', () => { }) try { - await new Promise(resolve => limitedServer.listen(0, '127.0.0.1', resolve)) + await new Promise((resolve) => limitedServer.listen(0, '127.0.0.1', resolve)) const limitedUrl = `http://127.0.0.1:${(limitedServer.address() as AddressInfo).port}` - const client: RouterClient = createORPCClient(new RPCLink({ - url: '/', - origin: limitedUrl, - plugins: [new RequestCompressionLinkPlugin()], - })) + const client: RouterClient = createORPCClient( + new RPCLink({ + url: '/', + origin: limitedUrl, + plugins: [new RequestCompressionLinkPlugin()], + }), + ) const content = new Uint8Array(2 * 1024 * 1024).fill(7) await expect(client.upload({ file: new File([content], 'too-large.bin') })).rejects.toThrow() @@ -192,8 +209,7 @@ describe('uploads large files through tmp files', () => { await vi.waitFor(() => { expect(readdirSync(limitedTmpDir)).toHaveLength(0) }) - } - finally { + } finally { limitedServer.close() rmSync(limitedTmpDir, { recursive: true, force: true }) } @@ -202,7 +218,12 @@ describe('uploads large files through tmp files', () => { it('rejects uploads over its own file limit without the request limit plugin', async () => { const limitedTmpDir = mkdtempSync(path.join(tmpdir(), 'orpc-upload-e2e-own-limit-')) const limitedHandler = new RPCHandler(router, { - plugins: [new TmpFileUploadHandlerPlugin({ tmpDir: limitedTmpDir, maxBodySize: { ...UNLIMITED, file: 64 * 1024 } })], + plugins: [ + new TmpFileUploadHandlerPlugin({ + tmpDir: limitedTmpDir, + maxBodySize: { ...UNLIMITED, file: 64 * 1024 }, + }), + ], }) const limitedServer = createServer(async (req, res) => { @@ -210,24 +231,28 @@ describe('uploads large files through tmp files', () => { }) try { - await new Promise(resolve => limitedServer.listen(0, '127.0.0.1', resolve)) - const client: RouterClient = createORPCClient(new RPCLink({ - url: '/', - origin: `http://127.0.0.1:${(limitedServer.address() as AddressInfo).port}`, - })) + await new Promise((resolve) => limitedServer.listen(0, '127.0.0.1', resolve)) + const client: RouterClient = createORPCClient( + new RPCLink({ + url: '/', + origin: `http://127.0.0.1:${(limitedServer.address() as AddressInfo).port}`, + }), + ) const content = new Uint8Array(2 * 1024 * 1024).fill(7) await expect(client.upload({ file: new File([content], 'too-large.bin') })).rejects.toThrow() // Under the limit the same server accepts the upload const small = new Uint8Array(16 * 1024).fill(7) - await expect(client.upload({ file: new File([small], 'small.bin') })).resolves.toMatchObject({ isTmpFile: false, size: small.length }) + await expect(client.upload({ file: new File([small], 'small.bin') })).resolves.toMatchObject({ + isTmpFile: false, + size: small.length, + }) await vi.waitFor(() => { expect(readdirSync(limitedTmpDir)).toHaveLength(0) }) - } - finally { + } finally { limitedServer.close() rmSync(limitedTmpDir, { recursive: true, force: true }) } @@ -236,30 +261,35 @@ describe('uploads large files through tmp files', () => { it('removes tmp files when the client abandons a streaming response', async () => { const streamTmpDir = mkdtempSync(path.join(tmpdir(), 'orpc-upload-e2e-stream-abort-')) - const streamHandler = new RPCHandler({ - stream: os.input(type()).handler(async function* ({ input }) { - yield await input.text() - // In-flight logic the abandoned transfer must still wait for before cleanup - await sleep(1500) - yield 'never delivered' - }), - }, { - plugins: [new TmpFileUploadHandlerPlugin({ tmpDir: streamTmpDir })], - }) + const streamHandler = new RPCHandler( + { + stream: os.input(type()).handler(async function* ({ input }) { + yield await input.text() + // In-flight logic the abandoned transfer must still wait for before cleanup + await sleep(1500) + yield 'never delivered' + }), + }, + { + plugins: [new TmpFileUploadHandlerPlugin({ tmpDir: streamTmpDir })], + }, + ) const streamServer = createServer(async (req, res) => { await streamHandler.handle(req, res, { context: {} }) }) try { - await new Promise(resolve => streamServer.listen(0, '127.0.0.1', resolve)) + await new Promise((resolve) => streamServer.listen(0, '127.0.0.1', resolve)) const { port } = streamServer.address() as AddressInfo const body = Buffer.from('held by the stream') await new Promise((resolve, reject) => { const socket = connect(port, '127.0.0.1', () => { - socket.write(`POST /stream HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/octet-stream\r\nContent-Length: ${body.length}\r\n\r\n`) + socket.write( + `POST /stream HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/octet-stream\r\nContent-Length: ${body.length}\r\n\r\n`, + ) socket.write(body) }) @@ -273,60 +303,70 @@ describe('uploads large files through tmp files', () => { socket.on('error', reject) }) - await vi.waitFor(() => { - expect(readdirSync(streamTmpDir)).toHaveLength(0) - }, { timeout: 5000 }) - } - finally { + await vi.waitFor( + () => { + expect(readdirSync(streamTmpDir)).toHaveLength(0) + }, + { timeout: 5000 }, + ) + } finally { streamServer.close() rmSync(streamTmpDir, { recursive: true, force: true }) } }) - it('completes drip-fed uploads whose bytes arrive in tiny segments', { timeout: 60_000 }, async () => { - const boundary = 'X-DRIP-BOUNDARY' - const content = Buffer.alloc(4096, 7) - const body = Buffer.concat([ - Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="data"\r\n\r\n{"json":{"file":{}},"maps":[[["file"]]]}\r\n--${boundary}\r\nContent-Disposition: form-data; name="0"; filename="drip.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`), - content, - Buffer.from(`\r\n--${boundary}--\r\n`), - ]) - - for (const segmentSize of [3, 61]) { - const response = await new Promise((resolve, reject) => { - const socket = connect((server.address() as AddressInfo).port, '127.0.0.1', () => { - socket.setNoDelay(true) - socket.write(`POST /upload HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: multipart/form-data; boundary=${boundary}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`) - - void (async () => { - for (let offset = 0; offset < body.length; offset += segmentSize) { - socket.write(body.subarray(offset, offset + segmentSize)) - await sleep(0) - } - })() - }) - - const chunks: Buffer[] = [] - const timer = setTimeout(() => { - socket.destroy() - reject(new Error(`request wedged at segment size ${segmentSize}`)) - }, 30_000) - socket.on('data', (chunk: Buffer) => chunks.push(chunk)) - socket.on('close', () => { - clearTimeout(timer) - resolve(Buffer.concat(chunks).toString()) + it( + 'completes drip-fed uploads whose bytes arrive in tiny segments', + { timeout: 60_000 }, + async () => { + const boundary = 'X-DRIP-BOUNDARY' + const content = Buffer.alloc(4096, 7) + const body = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="data"\r\n\r\n{"json":{"file":{}},"maps":[[["file"]]]}\r\n--${boundary}\r\nContent-Disposition: form-data; name="0"; filename="drip.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`, + ), + content, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]) + + for (const segmentSize of [3, 61]) { + const response = await new Promise((resolve, reject) => { + const socket = connect((server.address() as AddressInfo).port, '127.0.0.1', () => { + socket.setNoDelay(true) + socket.write( + `POST /upload HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: multipart/form-data; boundary=${boundary}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`, + ) + + void (async () => { + for (let offset = 0; offset < body.length; offset += segmentSize) { + socket.write(body.subarray(offset, offset + segmentSize)) + await sleep(0) + } + })() + }) + + const chunks: Buffer[] = [] + const timer = setTimeout(() => { + socket.destroy() + reject(new Error(`request wedged at segment size ${segmentSize}`)) + }, 30_000) + socket.on('data', (chunk: Buffer) => chunks.push(chunk)) + socket.on('close', () => { + clearTimeout(timer) + resolve(Buffer.concat(chunks).toString()) + }) + socket.on('error', reject) }) - socket.on('error', reject) - }) - expect(response, `segment size ${segmentSize}`).toContain('HTTP/1.1 200') - expect(response).toContain('"size":4096') - } + expect(response, `segment size ${segmentSize}`).toContain('HTTP/1.1 200') + expect(response).toContain('"size":4096') + } - await vi.waitFor(() => { - expect(readdirSync(tmpDir)).toHaveLength(0) - }) - }) + await vi.waitFor(() => { + expect(readdirSync(tmpDir)).toHaveLength(0) + }) + }, + ) it('removes spooled bytes when the client aborts mid-upload', async () => { const controller = new AbortController() diff --git a/packages/node/tests/works-with-response-compression.test.ts b/packages/node/tests/works-with-response-compression.test.ts index c576a903b3..bc2bbca0f2 100644 --- a/packages/node/tests/works-with-response-compression.test.ts +++ b/packages/node/tests/works-with-response-compression.test.ts @@ -1,13 +1,15 @@ -import type { AddressInfo } from 'node:net' import { Buffer } from 'node:buffer' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer, request as httpRequest } from 'node:http' +import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import path from 'node:path' import { brotliCompressSync, brotliDecompressSync, gunzipSync } from 'node:zlib' + import { RPCHandler } from '@orpc/server/node' import { ResponseCompressionHandlerPlugin } from '@orpc/server/plugins' -import { StaticFileHandlerPlugin } from '../src' + +import { StaticFileHandlerPlugin } from '../src/index.ts' /** * Driven over a raw socket rather than `fetch`, which negotiates its own encoding and @@ -27,12 +29,15 @@ it('works with the response compression plugin', async ({ onTestFinished }) => { writeFileSync(path.join(rootDir, 'bundle.js'), script) writeFileSync(path.join(rootDir, 'bundle.js.br'), brotliCompressSync(script)) - const handler = new RPCHandler({}, { - plugins: [ - new StaticFileHandlerPlugin({ rootDir, precompressed: true }), - new ResponseCompressionHandlerPlugin({ threshold: 1024 }), - ], - }) + const handler = new RPCHandler( + {}, + { + plugins: [ + new StaticFileHandlerPlugin({ rootDir, precompressed: true }), + new ResponseCompressionHandlerPlugin({ threshold: 1024 }), + ], + }, + ) const server = createServer(async (req, res) => { const result = await handler.handle(req, res, { context: {} }) @@ -46,22 +51,25 @@ it('works with the response compression plugin', async ({ onTestFinished }) => { server.close() }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const { port } = server.address() as AddressInfo - const get = (url: string, headers: Record = {}) => new Promise<{ - status: number | undefined - headers: Record - body: Buffer - }>((resolve, reject) => { - const req = httpRequest({ host: '127.0.0.1', port, path: url, headers }, (res) => { - const chunks: Buffer[] = [] - res.on('data', chunk => chunks.push(chunk)) - res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })) + const get = (url: string, headers: Record = {}) => + new Promise<{ + status: number | undefined + headers: Record + body: Buffer + }>((resolve, reject) => { + const req = httpRequest({ host: '127.0.0.1', port, path: url, headers }, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => + resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }), + ) + }) + req.on('error', reject) + req.end() }) - req.on('error', reject) - req.end() - }) // A compressible file over the threshold is compressed on the way out const compressed = await get('/app.js', { 'accept-encoding': 'gzip' }) diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index 8dc7c8b1b1..f8698a1c98 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -1,15 +1,7 @@ { "extends": "../../tsconfig.lib.json", "compilerOptions": { + "lib": ["ES2022"], "types": ["node"] - }, - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/*.bench.*", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + } } diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 8d26ef106c..eb97820772 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/openapi/build.config.ts b/packages/openapi/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/openapi/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/openapi/package.json b/packages/openapi/package.json index d9c526a4b4..1d17461efd 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -1,118 +1,101 @@ { "name": "@orpc/openapi", - "type": "module", "version": "2.0.0-beta.48", "description": "Generate OpenAPI specs from oRPC routers and serve OpenAPI-compliant APIs with end-to-end type safety", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "api", + "json-schema", + "openapi", + "orpc", + "rest", + "rpc", + "scalar", + "swagger", + "typesafe", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/openapi" }, - "keywords": [ - "orpc", - "rpc", - "api", - "typescript", - "typesafe", - "openapi", - "swagger", - "scalar", - "json-schema", - "rest" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": [ - "./dist/extensions/route.mjs" + "./dist/extensions/route.js" ], + "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", + "./aws-lambda": "./src/adapters/aws-lambda/index.ts", + "./fastify": "./src/adapters/fastify/index.ts", + "./extensions/route": "./src/extensions/route.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./helpers": { - "types": "./dist/helpers/index.d.mts", - "import": "./dist/helpers/index.mjs", - "default": "./dist/helpers/index.mjs" + "types": "./dist/helpers/index.d.ts", + "import": "./dist/helpers/index.js", + "default": "./dist/helpers/index.js" }, "./plugins": { - "types": "./dist/plugins/index.d.mts", - "import": "./dist/plugins/index.mjs", - "default": "./dist/plugins/index.mjs" + "types": "./dist/plugins/index.d.ts", + "import": "./dist/plugins/index.js", + "default": "./dist/plugins/index.js" }, "./standard": { - "types": "./dist/adapters/standard/index.d.mts", - "import": "./dist/adapters/standard/index.mjs", - "default": "./dist/adapters/standard/index.mjs" + "types": "./dist/adapters/standard/index.d.ts", + "import": "./dist/adapters/standard/index.js", + "default": "./dist/adapters/standard/index.js" }, "./fetch": { - "types": "./dist/adapters/fetch/index.d.mts", - "import": "./dist/adapters/fetch/index.mjs", - "default": "./dist/adapters/fetch/index.mjs" + "types": "./dist/adapters/fetch/index.d.ts", + "import": "./dist/adapters/fetch/index.js", + "default": "./dist/adapters/fetch/index.js" }, "./node": { - "types": "./dist/adapters/node/index.d.mts", - "import": "./dist/adapters/node/index.mjs", - "default": "./dist/adapters/node/index.mjs" + "types": "./dist/adapters/node/index.d.ts", + "import": "./dist/adapters/node/index.js", + "default": "./dist/adapters/node/index.js" }, "./aws-lambda": { - "types": "./dist/adapters/aws-lambda/index.d.mts", - "import": "./dist/adapters/aws-lambda/index.mjs", - "default": "./dist/adapters/aws-lambda/index.mjs" + "types": "./dist/adapters/aws-lambda/index.d.ts", + "import": "./dist/adapters/aws-lambda/index.js", + "default": "./dist/adapters/aws-lambda/index.js" }, "./fastify": { - "types": "./dist/adapters/fastify/index.d.mts", - "import": "./dist/adapters/fastify/index.mjs", - "default": "./dist/adapters/fastify/index.mjs" + "types": "./dist/adapters/fastify/index.d.ts", + "import": "./dist/adapters/fastify/index.js", + "default": "./dist/adapters/fastify/index.js" }, "./extensions/route": { - "types": "./dist/extensions/route.d.mts", - "import": "./dist/extensions/route.mjs", - "default": "./dist/extensions/route.mjs" + "types": "./dist/extensions/route.d.ts", + "import": "./dist/extensions/route.js", + "default": "./dist/extensions/route.js" } } }, - "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", - "./aws-lambda": "./src/adapters/aws-lambda/index.ts", - "./fastify": "./src/adapters/fastify/index.ts", - "./extensions/route": "./src/extensions/route.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "@scalar/api-reference": ">=1.57.2", - "@types/swagger-ui": ">=5.32.0", - "swagger-ui": ">=5.32.6" - }, - "peerDependenciesMeta": { - "@scalar/api-reference": { - "optional": true - }, - "@types/swagger-ui": { - "optional": true - }, - "swagger-ui": { - "optional": true - } + "prepack": "tsc" }, "dependencies": { "@openapi-spec/downgrader": "~0.6.0", @@ -133,5 +116,21 @@ "fastify": "^5.12.8", "swagger-ui": "^5.34.1", "zod": "^4.6.7" + }, + "peerDependencies": { + "@scalar/api-reference": ">=1.57.2", + "@types/swagger-ui": ">=5.32.0", + "swagger-ui": ">=5.32.6" + }, + "peerDependenciesMeta": { + "@scalar/api-reference": { + "optional": true + }, + "@types/swagger-ui": { + "optional": true + }, + "swagger-ui": { + "optional": true + } } } diff --git a/packages/openapi/src/adapters/aws-lambda/index.test.ts b/packages/openapi/src/adapters/aws-lambda/index.test.ts index 5d1d114b1e..180a443432 100644 --- a/packages/openapi/src/adapters/aws-lambda/index.test.ts +++ b/packages/openapi/src/adapters/aws-lambda/index.test.ts @@ -1,5 +1,5 @@ it('exports OpenAPIHandler', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ OpenAPIHandler: expect.any(Function), }) }) diff --git a/packages/openapi/src/adapters/aws-lambda/index.ts b/packages/openapi/src/adapters/aws-lambda/index.ts index b23afc0b93..97f227e39d 100644 --- a/packages/openapi/src/adapters/aws-lambda/index.ts +++ b/packages/openapi/src/adapters/aws-lambda/index.ts @@ -1,4 +1,4 @@ -export * from './openapi-handler' +export * from './openapi-handler.ts' export type { AnyAPIGatewayProxyEvent, diff --git a/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts b/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts index db2258468b..394479c938 100644 --- a/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts @@ -1,14 +1,20 @@ -import type { StandardHandlerPlugin } from '@orpc/server/standard' -import type { APIGatewayProxyEventV2, AwsLambdaGlobal, HttpResponseStream } from '@standard-server/aws-lambda' import { Buffer } from 'node:buffer' import { Writable } from 'node:stream' + import { os } from '@orpc/server' -import { openapi } from '../../meta' -import { OpenAPIHandler } from './openapi-handler' +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { + APIGatewayProxyEventV2, + AwsLambdaGlobal, + HttpResponseStream, +} from '@standard-server/aws-lambda' + +import { openapi } from '../../meta.ts' +import { OpenAPIHandler } from './openapi-handler.ts' class TestResponseStream extends Writable implements HttpResponseStream { chunks: Buffer[] = [] - metadata: { statusCode: number, headers: Record, cookies: string[] } | undefined + metadata: { statusCode: number; headers: Record; cookies: string[] } | undefined get text(): string { return Buffer.concat(this.chunks).toString() @@ -43,10 +49,10 @@ beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('awslambda', { - streamifyResponse: handler => handler, + streamifyResponse: (handler) => handler, HttpResponseStream: { from(responseStream, metadata) { - (responseStream as TestResponseStream).metadata = metadata + ;(responseStream as TestResponseStream).metadata = metadata return responseStream }, }, @@ -67,20 +73,28 @@ describe('openapiHandler', () => { }) const responseStream = new TestResponseStream() - const result = await handler.handle(createEvent({ rawPath: '/api/v1/ping/pong' }), responseStream, { - context: { userId: 'u_123' }, - prefix: '/api/v1', - }) + const result = await handler.handle( + createEvent({ rawPath: '/api/v1/ping/pong' }), + responseStream, + { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }, + ) expect(result.matched).toBe(true) expect(responseStream.metadata?.statusCode).toBe(200) expect(responseStream.text).toContain('u_123') const mismatchStream = new TestResponseStream() - const mismatchResult = await handler.handle(createEvent({ rawPath: '/invalid/ping' }), mismatchStream, { - context: { userId: 'u_123' }, - prefix: '/api/v1', - }) + const mismatchResult = await handler.handle( + createEvent({ rawPath: '/invalid/ping' }), + mismatchStream, + { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }, + ) expect(mismatchResult.matched).toBe(false) expect(mismatchStream.metadata).toBeUndefined() @@ -94,7 +108,10 @@ describe('openapiHandler', () => { return { ...options, routingInterceptors: [ - async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + async () => ({ + matched: true, + response: { status: 200, headers: {}, body: 'intercepted' }, + }), ], } }, @@ -103,7 +120,10 @@ describe('openapiHandler', () => { const handler = new OpenAPIHandler({}, { plugins: [plugin] }) const responseStream = new TestResponseStream() - const result = await handler.handle(createEvent({ requestContext: { http: { method: 'GET' } }, body: undefined }), responseStream) + const result = await handler.handle( + createEvent({ requestContext: { http: { method: 'GET' } }, body: undefined }), + responseStream, + ) expect(result.matched).toBe(true) expect(responseStream.text).toBe('"intercepted"') diff --git a/packages/openapi/src/adapters/aws-lambda/openapi-handler.ts b/packages/openapi/src/adapters/aws-lambda/openapi-handler.ts index 137c0c15a0..bb434bafc0 100644 --- a/packages/openapi/src/adapters/aws-lambda/openapi-handler.ts +++ b/packages/openapi/src/adapters/aws-lambda/openapi-handler.ts @@ -1,10 +1,11 @@ import type { Context, Router } from '@orpc/server' import type { AwsLambdaHandlerOptions } from '@orpc/server/aws-lambda' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { OpenAPIHandlerCodecOptions } from '../standard' import { AwsLambdaHandler } from '@orpc/server/aws-lambda' +import type { StandardHandlerOptions } from '@orpc/server/standard' import { StandardHandler } from '@orpc/server/standard' -import { OpenAPIHandlerCodec } from '../standard' + +import type { OpenAPIHandlerCodecOptions } from '../standard/index.ts' +import { OpenAPIHandlerCodec } from '../standard/index.ts' export interface OpenAPIHandlerOptions extends AwsLambdaHandlerOptions, StandardHandlerOptions, OpenAPIHandlerCodecOptions {} @@ -18,10 +19,7 @@ export interface OpenAPIHandlerOptions * @see {@link https://orpc.dev/docs/adapters/aws-lambda | AWS Lambda Adapter} */ export class OpenAPIHandler extends AwsLambdaHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + 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/fastify/index.test.ts b/packages/openapi/src/adapters/fastify/index.test.ts index 5d1d114b1e..180a443432 100644 --- a/packages/openapi/src/adapters/fastify/index.test.ts +++ b/packages/openapi/src/adapters/fastify/index.test.ts @@ -1,5 +1,5 @@ it('exports OpenAPIHandler', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ OpenAPIHandler: expect.any(Function), }) }) diff --git a/packages/openapi/src/adapters/fastify/index.ts b/packages/openapi/src/adapters/fastify/index.ts index a2825c287e..e70c9b07d5 100644 --- a/packages/openapi/src/adapters/fastify/index.ts +++ b/packages/openapi/src/adapters/fastify/index.ts @@ -1 +1 @@ -export * from './openapi-handler' +export * from './openapi-handler.ts' diff --git a/packages/openapi/src/adapters/fastify/openapi-handler.test.ts b/packages/openapi/src/adapters/fastify/openapi-handler.test.ts index 7b9f396d06..229f0cdeb3 100644 --- a/packages/openapi/src/adapters/fastify/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fastify/openapi-handler.test.ts @@ -1,10 +1,11 @@ +import { os } from '@orpc/server' import type { StandardHandlerPlugin } from '@orpc/server/standard' import type { FastifyInstance } from 'fastify' -import { os } from '@orpc/server' import Fastify from 'fastify' import request from 'supertest' -import { openapi } from '../../meta' -import { OpenAPIHandler } from './openapi-handler' + +import { openapi } from '../../meta.ts' +import { OpenAPIHandler } from './openapi-handler.ts' describe('openapiHandler', () => { let app: FastifyInstance @@ -41,12 +42,18 @@ describe('openapiHandler', () => { await app.ready() - const res = await request(app.server).post('/api/v1/ping/pong').set('content-type', 'application/json').send({ json: null }) + const res = await request(app.server) + .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(app.server).post('/invalid/ping').set('content-type', 'application/json').send({ json: null }) + const mismatchRes = await request(app.server) + .post('/invalid/ping') + .set('content-type', 'application/json') + .send({ json: null }) expect(mismatchRes.status).toBe(404) expect(mismatchRes.text).toBe('not matched') @@ -59,7 +66,10 @@ describe('openapiHandler', () => { return { ...options, routingInterceptors: [ - async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + async () => ({ + matched: true, + response: { status: 200, headers: {}, body: 'intercepted' }, + }), ], } }, diff --git a/packages/openapi/src/adapters/fastify/openapi-handler.ts b/packages/openapi/src/adapters/fastify/openapi-handler.ts index f516ab1a05..d14e8f9bcf 100644 --- a/packages/openapi/src/adapters/fastify/openapi-handler.ts +++ b/packages/openapi/src/adapters/fastify/openapi-handler.ts @@ -1,10 +1,11 @@ import type { Context, Router } from '@orpc/server' import type { FastifyHandlerOptions } from '@orpc/server/fastify' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { OpenAPIHandlerCodecOptions } from '../standard' import { FastifyHandler } from '@orpc/server/fastify' +import type { StandardHandlerOptions } from '@orpc/server/standard' import { StandardHandler } from '@orpc/server/standard' -import { OpenAPIHandlerCodec } from '../standard' + +import type { OpenAPIHandlerCodecOptions } from '../standard/index.ts' +import { OpenAPIHandlerCodec } from '../standard/index.ts' export interface OpenAPIHandlerOptions extends FastifyHandlerOptions, StandardHandlerOptions, OpenAPIHandlerCodecOptions {} @@ -15,10 +16,7 @@ export interface OpenAPIHandlerOptions * @see {@link https://orpc.dev/docs/adapters/fastify | Fastify Adapter} */ export class OpenAPIHandler extends FastifyHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + 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/index.test.ts b/packages/openapi/src/adapters/fetch/index.test.ts index 117a49488c..201d6e494b 100644 --- a/packages/openapi/src/adapters/fetch/index.test.ts +++ b/packages/openapi/src/adapters/fetch/index.test.ts @@ -1,5 +1,5 @@ it('exports OpenAPIHandler and OpenAPILink', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).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 8e9995cf64..05e5ba503b 100644 --- a/packages/openapi/src/adapters/fetch/index.ts +++ b/packages/openapi/src/adapters/fetch/index.ts @@ -1,2 +1,2 @@ -export * from './openapi-handler' -export * from './openapi-link' +export * from './openapi-handler.ts' +export * from './openapi-link.ts' diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index 2d0fea0fbb..1a9afbf67a 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts @@ -1,9 +1,10 @@ -import type { StandardHandlerPlugin } from '@orpc/server/standard' import { createORPCClient } from '@orpc/client' import { os } from '@orpc/server' -import { openapi } from '../../meta' -import { OpenAPIHandler } from './openapi-handler' -import { OpenAPILink } from './openapi-link' +import type { StandardHandlerPlugin } from '@orpc/server/standard' + +import { openapi } from '../../meta.ts' +import { OpenAPIHandler } from './openapi-handler.ts' +import { OpenAPILink } from './openapi-link.ts' describe('openapiHandler', () => { beforeEach(() => { @@ -53,7 +54,9 @@ describe('openapiHandler', () => { it('routes an OpenAPILink call with a raw char to its static route, not a dynamic sibling', async () => { const router = { beta: os.meta(openapi({ method: 'GET', path: '/v1^beta/items' })).handler(() => 'beta'), - version: os.meta(openapi({ method: 'GET', path: '/{version}/items' })).handler(() => 'version'), + version: os + .meta(openapi({ method: 'GET', path: '/{version}/items' })) + .handler(() => 'version'), } const handler = new OpenAPIHandler(router) @@ -68,14 +71,21 @@ describe('openapiHandler', () => { return response }) - const client = createORPCClient(new OpenAPILink(router, { - fetch, - origin: 'http://localhost:3000', - url: '/api', - })) + const client = createORPCClient( + new OpenAPILink(router, { + fetch, + origin: 'http://localhost:3000', + url: '/api', + }), + ) await expect(client.beta()).resolves.toBe('beta') - expect(fetch).toHaveBeenCalledWith('http://localhost:3000/api/v1^beta/items', expect.anything(), expect.anything(), ['beta']) + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1^beta/items', + expect.anything(), + expect.anything(), + ['beta'], + ) }) it('supports standard handler plugins', async () => { @@ -85,7 +95,10 @@ describe('openapiHandler', () => { return { ...options, routingInterceptors: [ - async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + async () => ({ + matched: true, + response: { status: 200, headers: {}, body: 'intercepted' }, + }), ], } }, diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.ts b/packages/openapi/src/adapters/fetch/openapi-handler.ts index 4cd225f901..2254dc1316 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.ts @@ -1,10 +1,11 @@ import type { Context, Router } from '@orpc/server' import type { FetchHandlerOptions } from '@orpc/server/fetch' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { OpenAPIHandlerCodecOptions } from '../standard' import { FetchHandler } from '@orpc/server/fetch' +import type { StandardHandlerOptions } from '@orpc/server/standard' import { StandardHandler } from '@orpc/server/standard' -import { OpenAPIHandlerCodec } from '../standard' + +import type { OpenAPIHandlerCodecOptions } from '../standard/index.ts' +import { OpenAPIHandlerCodec } from '../standard/index.ts' export interface OpenAPIHandlerOptions extends FetchHandlerOptions, StandardHandlerOptions, OpenAPIHandlerCodecOptions {} @@ -15,10 +16,7 @@ export interface OpenAPIHandlerOptions * @see {@link https://orpc.dev/docs/adapters/fetch-api | Fetch API Adapter} */ export class OpenAPIHandler extends FetchHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + 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 index 70ac895692..c6f0221c4f 100644 --- a/packages/openapi/src/adapters/fetch/openapi-link.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-link.test.ts @@ -1,22 +1,19 @@ -import type { StandardLinkPlugin } from '@orpc/client/standard' import { createORPCClient } from '@orpc/client' +import type { StandardLinkPlugin } from '@orpc/client/standard' import { os } from '@orpc/server' -import { openapi } from '../../meta' -import { OpenAPIHandler } from './openapi-handler' -import { OpenAPILink } from './openapi-link' + +import { openapi } from '../../meta.ts' +import { OpenAPIHandler } from './openapi-handler.ts' +import { OpenAPILink } from './openapi-link.ts' 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), + get: os.meta(openapi({ method: 'GET', path: '/ping/{pong}' })).handler(({ input }) => input), post: os.handler(({ input }) => input), - query: os - .meta(openapi({ method: 'QUERY', path: '/query' })) - .handler(({ input }) => input), + query: os.meta(openapi({ method: 'QUERY', path: '/query' })).handler(({ input }) => input), } const handler = new OpenAPIHandler(router) @@ -39,20 +36,24 @@ describe('openapiLink', () => { 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({ + 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: { @@ -74,31 +75,35 @@ describe('openapiLink', () => { }) 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({ + 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: { @@ -125,11 +130,13 @@ describe('openapiLink', () => { return response }) - const client = createORPCClient(new OpenAPILink(router, { - fetch, - origin: 'http://localhost:3000', - url: '/api', - })) as any + const client = createORPCClient( + new OpenAPILink(router, { + fetch, + origin: 'http://localhost:3000', + url: '/api', + }), + ) as any await expect(client.query({ search: 'earth' })).resolves.toEqual({ search: 'earth' }) @@ -142,31 +149,35 @@ describe('openapiLink', () => { }) 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({ + 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(), @@ -192,9 +203,11 @@ describe('openapiLink', () => { }, } - const client = createORPCClient(new OpenAPILink(router, { - plugins: [plugin], - })) as any + 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 index a0cd807ac3..48196899c6 100644 --- a/packages/openapi/src/adapters/fetch/openapi-link.ts +++ b/packages/openapi/src/adapters/fetch/openapi-link.ts @@ -1,15 +1,15 @@ 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 type { StandardLinkOptions } from '@orpc/client/standard' import { StandardLink } from '@orpc/client/standard' -import { OpenAPILinkCodec } from '../standard' +import type { RouterContract } from '@orpc/contract' + +import type { OpenAPILinkCodecOptions } from '../standard/index.ts' +import { OpenAPILinkCodec } from '../standard/index.ts' export interface OpenAPILinkOptions - extends StandardLinkOptions, FetchLinkTransportOptions, OpenAPILinkCodecOptions { -} + extends StandardLinkOptions, FetchLinkTransportOptions, OpenAPILinkCodecOptions {} /** * Client link that calls an OpenAPI (RESTful) oRPC server over HTTP using the Fetch API. @@ -17,10 +17,7 @@ export interface OpenAPILinkOptions * @see {@link https://orpc.dev/docs/openapi/link | OpenAPI Link} */ export class OpenAPILink extends StandardLink { - constructor( - router: RouterContract, - options: OpenAPILinkOptions = {}, - ) { + constructor(router: RouterContract, options: OpenAPILinkOptions = {}) { const codec = new OpenAPILinkCodec(router, options) const transport = new FetchLinkTransport(options) diff --git a/packages/openapi/src/adapters/node/index.test.ts b/packages/openapi/src/adapters/node/index.test.ts index 5d1d114b1e..180a443432 100644 --- a/packages/openapi/src/adapters/node/index.test.ts +++ b/packages/openapi/src/adapters/node/index.test.ts @@ -1,5 +1,5 @@ it('exports OpenAPIHandler', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ OpenAPIHandler: expect.any(Function), }) }) diff --git a/packages/openapi/src/adapters/node/index.ts b/packages/openapi/src/adapters/node/index.ts index a2825c287e..e70c9b07d5 100644 --- a/packages/openapi/src/adapters/node/index.ts +++ b/packages/openapi/src/adapters/node/index.ts @@ -1 +1 @@ -export * from './openapi-handler' +export * from './openapi-handler.ts' diff --git a/packages/openapi/src/adapters/node/openapi-handler.test.ts b/packages/openapi/src/adapters/node/openapi-handler.test.ts index 8bfa19661f..1f417b5f3b 100644 --- a/packages/openapi/src/adapters/node/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/node/openapi-handler.test.ts @@ -1,9 +1,11 @@ -import type { StandardHandlerPlugin } from '@orpc/server/standard' import type { IncomingMessage, ServerResponse } from 'node:http' + import { os } from '@orpc/server' +import type { StandardHandlerPlugin } from '@orpc/server/standard' import request from 'supertest' -import { openapi } from '../../meta' -import { OpenAPIHandler } from './openapi-handler' + +import { openapi } from '../../meta.ts' +import { OpenAPIHandler } from './openapi-handler.ts' describe('openapiHandler', () => { beforeEach(() => { @@ -23,7 +25,10 @@ describe('openapiHandler', () => { context: { userId: 'u_123' }, prefix: '/api/v1', }) - }).post('/api/v1/ping/pong').set('content-type', 'application/json').send({ json: null }) + }) + .post('/api/v1/ping/pong') + .set('content-type', 'application/json') + .send({ json: null }) expect(res.status).toBe(200) expect(res.text).toContain('u_123') @@ -38,7 +43,10 @@ describe('openapiHandler', () => { response.statusCode = 404 response.end('not matched') } - }).post('/invalid/ping').set('content-type', 'application/json').send({ json: null }) + }) + .post('/invalid/ping') + .set('content-type', 'application/json') + .send({ json: null }) expect(mismatchRes.status).toBe(404) expect(mismatchRes.text).toBe('not matched') @@ -51,7 +59,10 @@ describe('openapiHandler', () => { return { ...options, routingInterceptors: [ - async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + async () => ({ + matched: true, + response: { status: 200, headers: {}, body: 'intercepted' }, + }), ], } }, diff --git a/packages/openapi/src/adapters/node/openapi-handler.ts b/packages/openapi/src/adapters/node/openapi-handler.ts index b97e22fdfa..c6be6f6957 100644 --- a/packages/openapi/src/adapters/node/openapi-handler.ts +++ b/packages/openapi/src/adapters/node/openapi-handler.ts @@ -1,10 +1,11 @@ import type { Context, Router } from '@orpc/server' import type { NodeHttpHandlerOptions } from '@orpc/server/node' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { OpenAPIHandlerCodecOptions } from '../standard' import { NodeHttpHandler } from '@orpc/server/node' +import type { StandardHandlerOptions } from '@orpc/server/standard' import { StandardHandler } from '@orpc/server/standard' -import { OpenAPIHandlerCodec } from '../standard' + +import type { OpenAPIHandlerCodecOptions } from '../standard/index.ts' +import { OpenAPIHandlerCodec } from '../standard/index.ts' export interface OpenAPIHandlerOptions extends NodeHttpHandlerOptions, StandardHandlerOptions, OpenAPIHandlerCodecOptions {} @@ -15,10 +16,7 @@ export interface OpenAPIHandlerOptions * @see {@link https://orpc.dev/docs/adapters/node-http | Node HTTP Adapter} */ export class OpenAPIHandler extends NodeHttpHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + 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 index fe77888524..5794975110 100644 --- a/packages/openapi/src/adapters/standard/index.test.ts +++ b/packages/openapi/src/adapters/standard/index.test.ts @@ -1,5 +1,5 @@ it('exports OpenAPIMatcher, OpenAPIHandlerCodec, OpenAPILinkCodec', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).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 a754da32a1..46ee51c543 100644 --- a/packages/openapi/src/adapters/standard/index.ts +++ b/packages/openapi/src/adapters/standard/index.ts @@ -1,3 +1,3 @@ -export * from './openapi-handler-codec' -export * from './openapi-link-codec' -export * from './openapi-matcher' +export * from './openapi-handler-codec.ts' +export * from './openapi-link-codec.ts' +export * from './openapi-matcher.ts' diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts index cbc9ad8bca..b8844bb35c 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts @@ -1,8 +1,9 @@ -import type { StandardLazyRequest } from '@standard-server/core' import { ORPCError } from '@orpc/client' import { DEFAULT_ERROR_STATUS, os } from '@orpc/server' -import { openapi } from '../../meta' -import { OpenAPIHandlerCodec } from './openapi-handler-codec' +import type { StandardLazyRequest } from '@standard-server/core' + +import { openapi } from '../../meta.ts' +import { OpenAPIHandlerCodec } from './openapi-handler-codec.ts' beforeEach(() => { vi.clearAllMocks() @@ -31,10 +32,13 @@ describe('openAPIHandlerCodec', () => { os.meta(openapi({ method: 'GET', path: '/items' })).handler(vi.fn()), ) - const result = await codec.resolveProcedure(createRequest({ - method: 'GET', - url: '/missing', - }), options as any) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/missing', + }), + options as any, + ) expect(result).toBeUndefined() }) @@ -45,13 +49,16 @@ describe('openAPIHandlerCodec', () => { .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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/gateway/api/v1/items/42', + }), + { + ...options, + prefix: '/gateway', + } as any, + ) expect(result).toBeDefined() @@ -63,17 +70,18 @@ describe('openAPIHandlerCodec', () => { 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 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) + 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) @@ -88,17 +96,18 @@ describe('openAPIHandlerCodec', () => { }) it('treats HEAD like GET, merging path params and query without reading the body', async () => { - const procedure = os - .meta(openapi({ method: 'HEAD', path: '/{id}' })) - .handler(vi.fn()) + const procedure = os.meta(openapi({ method: 'HEAD', path: '/{id}' })).handler(vi.fn()) const codec = new OpenAPIHandlerCodec(procedure) const resolveBody = vi.fn() - const result = await codec.resolveProcedure(createRequest({ - method: 'HEAD', - url: '/42?verbose=true', - resolveBody, - }), options as any) + const result = await codec.resolveProcedure( + createRequest({ + method: 'HEAD', + url: '/42?verbose=true', + resolveBody, + }), + options as any, + ) expect(result).toBeDefined() @@ -111,15 +120,16 @@ describe('openAPIHandlerCodec', () => { }) it('gives path params precedence over conflicting query params', async () => { - const procedure = os - .meta(openapi({ method: 'GET', path: '/{id}' })) - .handler(vi.fn()) + 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?id=99&q=hello', - }), options as any) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/42?id=99&q=hello', + }), + options as any, + ) expect(result).toBeDefined() @@ -130,15 +140,16 @@ describe('openAPIHandlerCodec', () => { }) it('returns query directly when there are no path params', async () => { - const procedure = os - .meta(openapi({ method: 'GET', path: '/status' })) - .handler(vi.fn()) + 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) + 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) @@ -150,15 +161,16 @@ describe('openAPIHandlerCodec', () => { }) 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 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/42?0=zero&1=one', + }), + options as any, + ) expect(result).toBeDefined() @@ -171,21 +183,28 @@ describe('openAPIHandlerCodec', () => { 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()), + 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/42/red,blue/size,large,brand,nike?page=2', + }), + options as any, + ) expect(result).toBeDefined() @@ -198,34 +217,54 @@ describe('openAPIHandlerCodec', () => { }) it('ignores paramsStyles keys that are not path params of the matched route', async () => { - const base = os.meta(openapi({ paramsStyles: { tags: 'comma-delimited-array', filters: 'comma-delimited-object' } })) + const base = os.meta( + openapi({ + paramsStyles: { tags: 'comma-delimited-array', filters: 'comma-delimited-object' }, + }), + ) const codec = new OpenAPIHandlerCodec({ list: base.meta(openapi({ method: 'GET', path: '/items/{category}' })).handler(vi.fn()), - create: base.meta(openapi({ method: 'POST', path: '/items/{category}' })).handler(vi.fn()), - detailed: base.meta(openapi({ method: 'PUT', path: '/items/{category}', inputStructure: 'detailed' })).handler(vi.fn()), + create: base + .meta(openapi({ method: 'POST', path: '/items/{category}' })) + .handler(vi.fn()), + detailed: base + .meta(openapi({ method: 'PUT', path: '/items/{category}', inputStructure: 'detailed' })) + .handler(vi.fn()), }) - const list = await codec.resolveProcedure(createRequest({ - method: 'GET', - url: '/items/books?tags=a&tags=b', - }), options as any) + const list = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/items/books?tags=a&tags=b', + }), + options as any, + ) await expect(list!.decodeInput()).resolves.toEqual({ category: 'books', tags: ['a', 'b'] }) - const create = await codec.resolveProcedure(createRequest({ - method: 'POST', - url: '/items/books', - resolveBody: vi.fn().mockResolvedValueOnce({ tags: ['a', 'b'] }), - }), options as any) + const create = await codec.resolveProcedure( + createRequest({ + method: 'POST', + url: '/items/books', + resolveBody: vi.fn().mockResolvedValueOnce({ tags: ['a', 'b'] }), + }), + options as any, + ) - await expect(create!.decodeInput()).resolves.toEqual({ category: 'books', tags: ['a', 'b'] }) + await expect(create!.decodeInput()).resolves.toEqual({ + category: 'books', + tags: ['a', 'b'], + }) - const detailed = await codec.resolveProcedure(createRequest({ - method: 'PUT', - url: '/items/books', - }), options as any) + const detailed = await codec.resolveProcedure( + createRequest({ + method: 'PUT', + url: '/items/books', + }), + options as any, + ) - const input = await detailed!.decodeInput() as any + const input = (await detailed!.decodeInput()) as any expect(Object.keys(input.params)).toEqual(['category']) }) }) @@ -238,15 +277,20 @@ describe('openAPIHandlerCodec', () => { } as any const codec = new OpenAPIHandlerCodec( - os.meta(openapi({ method: 'POST', path: '/{id}', requestBodyHint: 'url-search-params' })).handler(vi.fn()), + 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), + options as any, + ) expect(result).toBeDefined() @@ -262,16 +306,21 @@ describe('openAPIHandlerCodec', () => { .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 resolveBody = vi.fn().mockResolvedValueOnce( + new URLSearchParams([ + ['title', 'hello'], + ['published', 'true'], + ]), + ) - const result = await codec.resolveProcedure(createRequest({ - method: 'POST', - url: '/24', - resolveBody, - }), options as any) + const result = await codec.resolveProcedure( + createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), + options as any, + ) expect(result).toBeDefined() expect(result!.procedure).toBe(procedure) @@ -291,16 +340,21 @@ describe('openAPIHandlerCodec', () => { .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([ - ['id', '99'], - ['title', 'hello'], - ])) + const resolveBody = vi.fn().mockResolvedValueOnce( + new URLSearchParams([ + ['id', '99'], + ['title', 'hello'], + ]), + ) - const result = await codec.resolveProcedure(createRequest({ - method: 'POST', - url: '/24', - resolveBody, - }), options as any) + const result = await codec.resolveProcedure( + createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), + options as any, + ) expect(result).toBeDefined() @@ -317,16 +371,21 @@ describe('openAPIHandlerCodec', () => { } as any const codec = new OpenAPIHandlerCodec( - os.meta(openapi({ method: 'POST', path: '/{id}', requestBodyHint: 'url-search-params' })).handler(vi.fn()), + 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), + options as any, + ) expect(result).toBeDefined() @@ -340,17 +399,18 @@ describe('openAPIHandlerCodec', () => { deserialize: vi.fn().mockReturnValueOnce({ name: 'din' }), } as any - const procedure = os - .meta(openapi({ method: 'POST', path: '/submit' })) - .handler(vi.fn()) + 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'POST', + url: '/submit', + resolveBody, + }), + options as any, + ) expect(result).toBeDefined() expect(result!.procedure).toBe(procedure) @@ -371,11 +431,14 @@ describe('openAPIHandlerCodec', () => { 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), + options as any, + ) expect(result).toBeDefined() expect(result!.procedure).toBe(procedure) @@ -396,11 +459,14 @@ describe('openAPIHandlerCodec', () => { ) const resolveBody = vi.fn().mockResolvedValueOnce(blob) - const result = await codec.resolveProcedure(createRequest({ - method: 'POST', - url: '/24', - resolveBody, - }), options as any) + const result = await codec.resolveProcedure( + createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), + options as any, + ) expect(result).toBeDefined() @@ -420,11 +486,14 @@ describe('openAPIHandlerCodec', () => { ) const resolveBody = vi.fn().mockResolvedValueOnce(blob) - const result = await codec.resolveProcedure(createRequest({ - method: 'POST', - url: '/submit', - resolveBody, - }), options as any) + const result = await codec.resolveProcedure( + createRequest({ + method: 'POST', + url: '/submit', + resolveBody, + }), + options as any, + ) expect(result).toBeDefined() @@ -435,25 +504,32 @@ describe('openAPIHandlerCodec', () => { 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', - })) + .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) + 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) @@ -471,26 +547,31 @@ describe('openAPIHandlerCodec', () => { 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', - }, - })) + .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) + 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() @@ -508,26 +589,31 @@ describe('openAPIHandlerCodec', () => { 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', - }, - })) + .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) + 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() @@ -548,21 +634,28 @@ describe('openAPIHandlerCodec', () => { 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()), + 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) + 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() @@ -580,10 +673,13 @@ describe('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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/?0=zero&1=one', + }), + options as any, + ) expect(result).toBeDefined() @@ -596,13 +692,16 @@ describe('openAPIHandlerCodec', () => { 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()), + 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/?0=zero&1=one', + }), + options as any, + ) expect(result).toBeDefined() @@ -613,10 +712,13 @@ describe('openAPIHandlerCodec', () => { 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/?meta=not-json', + }), + options as any, + ) expect(result).toBeDefined() @@ -627,23 +729,30 @@ describe('openAPIHandlerCodec', () => { 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()), + 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) + 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() @@ -659,23 +768,30 @@ describe('openAPIHandlerCodec', () => { 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()), + 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/', + }), + options as any, + ) expect(result).toBeDefined() @@ -691,19 +807,26 @@ describe('openAPIHandlerCodec', () => { 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()), + 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) + const result = await codec.resolveProcedure( + createRequest({ + method: 'GET', + url: '/?keep[enabled]=true&tags=red&tags=blue', + }), + options as any, + ) expect(result).toBeDefined() @@ -774,9 +897,13 @@ describe('openAPIHandlerCodec', () => { 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'] as any) + const response = codec.encodeOutput( + { + body: { ok: true }, + }, + procedure, + ['detailed'] as any, + ) expect(response).toEqual({ status: 200, @@ -794,12 +921,18 @@ describe('openAPIHandlerCodec', () => { deserialize: vi.fn(), } as any - const procedure = os.meta(openapi({ outputStructure: 'detailed', successStatus: 201 })).handler(vi.fn()) + 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'] as any) + const response = codec.encodeOutput( + { + body: { ok: true }, + }, + procedure, + ['detailed'] as any, + ) expect(response).toEqual({ status: 201, @@ -815,11 +948,15 @@ describe('openAPIHandlerCodec', () => { const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn()) const codec = new OpenAPIHandlerCodec(procedure) - const response = codec.encodeOutput({ - status: 202, - headers: { 'x-custom': 'value' }, - body: { ok: true }, - }, procedure, []) + const response = codec.encodeOutput( + { + status: 202, + headers: { 'x-custom': 'value' }, + body: { ok: true }, + }, + procedure, + [], + ) expect(response).toEqual({ status: 202, @@ -832,10 +969,14 @@ describe('openAPIHandlerCodec', () => { const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn()) const codec = new OpenAPIHandlerCodec(procedure) - const response = codec.encodeOutput({ - status: 302, - headers: { location: 'https://example.com' }, - }, procedure, []) + const response = codec.encodeOutput( + { + status: 302, + headers: { location: 'https://example.com' }, + }, + procedure, + [], + ) expect(response).toEqual({ status: 302, @@ -848,18 +989,22 @@ describe('openAPIHandlerCodec', () => { const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn()) const codec = new OpenAPIHandlerCodec(procedure) - const response = codec.encodeOutput({ - headers: { - 'x-string': 'value', - 'x-number': 42, - 'x-boolean': true, - 'x-date': new Date('2020-01-02T03:04:05.000Z'), - 'x-array': ['a', 1, null, undefined], - 'x-null': null, - 'x-undefined': undefined, + const response = codec.encodeOutput( + { + headers: { + 'x-string': 'value', + 'x-number': 42, + 'x-boolean': true, + 'x-date': new Date('2020-01-02T03:04:05.000Z'), + 'x-array': ['a', 1, null, undefined], + 'x-null': null, + 'x-undefined': undefined, + }, + body: undefined, }, - body: undefined, - }, procedure, []) as any + procedure, + [], + ) as any expect(response.headers).toEqual({ 'x-string': 'value', @@ -876,14 +1021,20 @@ describe('openAPIHandlerCodec', () => { const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn()) const codec = new OpenAPIHandlerCodec(procedure) - const response = codec.encodeOutput({ - headers: JSON.parse('{"__proto__": "injected", "constructor": "c", "x-safe": "ok"}'), - }, procedure, []) as any + const response = codec.encodeOutput( + { + headers: JSON.parse('{"__proto__": "injected", "constructor": "c", "x-safe": "ok"}'), + }, + procedure, + [], + ) as any expect(response.headers['x-safe']).toBe('ok') // `__proto__` and `constructor` become plain own properties, not prototype-chain mutations - expect(Object.getOwnPropertyDescriptor(response.headers, '__proto__')?.value).toBe('injected') + expect(Object.getOwnPropertyDescriptor(response.headers, '__proto__')?.value).toBe( + 'injected', + ) expect(Object.getOwnPropertyDescriptor(response.headers, 'constructor')?.value).toBe('c') }) @@ -898,7 +1049,9 @@ describe('openAPIHandlerCodec', () => { const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn()) const codec = new OpenAPIHandlerCodec(procedure) - expect(() => codec.encodeOutput(output, procedure, [])).toThrow('Invalid "detailed" output structure') + expect(() => codec.encodeOutput(output, procedure, [])).toThrow( + 'Invalid "detailed" output structure', + ) }) }) }) @@ -970,7 +1123,9 @@ describe('openAPIHandlerCodec', () => { it('sets content-type header via errorResponseContentType option', () => { const procedure = os.handler(vi.fn()) - const codec = new OpenAPIHandlerCodec(procedure, { errorResponseContentType: 'application/problem+json' }) + const codec = new OpenAPIHandlerCodec(procedure, { + errorResponseContentType: 'application/problem+json', + }) const error = new ORPCError('BAD_GATEWAY') expect(codec.encodeError(error)).toEqual({ @@ -984,7 +1139,9 @@ describe('openAPIHandlerCodec', () => { const serializer = { serialize: vi.fn(), deserialize: vi.fn() } as any const codec = new OpenAPIHandlerCodec({ procedure: os.handler(vi.fn()) }, { serializer }) - expect((await codec.encodeError(new ORPCError('toString' as any))).status).toEqual(DEFAULT_ERROR_STATUS) + expect((await codec.encodeError(new ORPCError('toString' as any))).status).toEqual( + DEFAULT_ERROR_STATUS, + ) }) it('can custom error status via errorStatuses option', () => { @@ -994,10 +1151,13 @@ describe('openAPIHandlerCodec', () => { } as any const procedure = os.handler(vi.fn()) - const codec = new OpenAPIHandlerCodec({ procedure }, { - serializer, - errorStatusMap: { BAD_GATEWAY: 599 }, - }) + const codec = new OpenAPIHandlerCodec( + { procedure }, + { + serializer, + errorStatusMap: { BAD_GATEWAY: 599 }, + }, + ) const error = new ORPCError('BAD_GATEWAY') const response = codec.encodeError(error) @@ -1011,16 +1171,18 @@ describe('openAPIHandlerCodec', () => { it('fallback unknown error code to DEFAULT_ERROR_STATUS', () => { const serializer = { - serialize: vi.fn() - .mockReturnValueOnce('__serialized_unknown__'), + 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 codec = new OpenAPIHandlerCodec( + { procedure }, + { + serializer, + errorStatusMap: {}, + }, + ) const unknownError = new ORPCError('UNKNOWN_CODE' as any) const unknownResponse = codec.encodeError(unknownError) diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts index ed8085dae7..bffc123e07 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts @@ -1,23 +1,35 @@ import type { AnyORPCError } from '@orpc/client' +import { COMMON_ERROR_STATUS_MAP } from '@orpc/client' import type { AnyProcedure, AnyRouter, Context } from '@orpc/server' -import type { ResolvedStandardHandlerHandleOptions, StandardHandlerCodec, StandardHandlerCodecResolvedProcedure } from '@orpc/server/standard' +import { DEFAULT_ERROR_STATUS, DEFAULT_SUCCESS_STATUS } from '@orpc/server' +import type { + ResolvedStandardHandlerHandleOptions, + StandardHandlerCodec, + StandardHandlerCodecResolvedProcedure, +} from '@orpc/server/standard' import type { Promisable, Public } from '@orpc/shared' +import { + getOwn, + isPlainObject, + isTypescriptObject, + NullProtoObj, + parseEmptyableJSON, + stringifyJSON, +} from '@orpc/shared' import type { StandardLazyRequest, StandardResponse } from '@standard-server/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 { getOwn, isPlainObject, isTypescriptObject, NullProtoObj, parseEmptyableJSON, stringifyJSON } from '@orpc/shared' import { parseStandardUrl } from '@standard-server/core' + import { DEFAULT_OPENAPI_INPUT_STRUCTURE, DEFAULT_OPENAPI_OUTPUT_STRUCTURE, -} from '../../constants' -import { getOpenAPIMeta } from '../../meta' -import { OpenAPISerializer } from '../../openapi-serializer' -import { isBodylessMethod } from '../../utils' -import { OpenAPIMatcher } from './openapi-matcher' -import { serializeHeaders } from './utils' +} from '../../constants.ts' +import type { OpenAPIMeta } from '../../meta.ts' +import { getOpenAPIMeta } from '../../meta.ts' +import { OpenAPISerializer } from '../../openapi-serializer.ts' +import { isBodylessMethod } from '../../utils.ts' +import type { OpenAPIMatcherOptions } from './openapi-matcher.ts' +import { OpenAPIMatcher } from './openapi-matcher.ts' +import { serializeHeaders } from './utils.ts' export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> { /** @@ -55,19 +67,25 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> { export class OpenAPIHandlerCodecCore { private readonly serializer: Public - private readonly errorStatusMap: Exclude['errorStatusMap'], undefined> - private readonly errorResponseBodyEncoder: Exclude['errorResponseBodyEncoder'], undefined> + private readonly errorStatusMap: Exclude< + OpenAPIHandlerCodecOptions['errorStatusMap'], + undefined + > + private readonly errorResponseBodyEncoder: Exclude< + OpenAPIHandlerCodecOptions['errorResponseBodyEncoder'], + undefined + > private readonly errorResponseContentType: OpenAPIHandlerCodecOptions['errorResponseContentType'] constructor(options: OpenAPIHandlerCodecCoreOptions = {}) { this.serializer = options.serializer ?? new OpenAPISerializer() this.errorStatusMap = options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP - this.errorResponseBodyEncoder = options.errorResponseBodyEncoder ?? (e => e.toJSON()) + this.errorResponseBodyEncoder = options.errorResponseBodyEncoder ?? ((e) => e.toJSON()) this.errorResponseContentType = options.errorResponseContentType } async decodeInput( - matched: { procedure: AnyProcedure, params?: Record | undefined }, + matched: { procedure: AnyProcedure; params?: Record | undefined }, request: StandardLazyRequest, ): Promise { const meta = getOpenAPIMeta(matched.procedure) @@ -110,7 +128,11 @@ export class OpenAPIHandlerCodecCore { /** * @throws {TypeError} If `outputStructure` is "detailed" and the output doesn't match the expected structure. */ - encodeOutput(output: unknown, procedure: AnyProcedure, path: string[]): Promisable { + encodeOutput( + output: unknown, + procedure: AnyProcedure, + path: string[], + ): Promisable { const meta = getOpenAPIMeta(procedure) const successStatus = meta?.successStatus ?? DEFAULT_SUCCESS_STATUS const outputStructure = meta?.outputStructure ?? DEFAULT_OPENAPI_OUTPUT_STRUCTURE @@ -139,7 +161,8 @@ export class OpenAPIHandlerCodecCore { return { status: output.status ?? successStatus, - headers: output.headers !== undefined ? serializeHeaders(output.headers, this.serializer) : {}, + headers: + output.headers !== undefined ? serializeHeaders(output.headers, this.serializer) : {}, body: this.serializer.serialize(output.body), } } @@ -149,7 +172,10 @@ export class OpenAPIHandlerCodecCore { return { status, - headers: this.errorResponseContentType !== undefined ? { 'content-type': this.errorResponseContentType } : {}, + headers: + this.errorResponseContentType !== undefined + ? { 'content-type': this.errorResponseContentType } + : {}, body: this.serializer.serialize(this.errorResponseBodyEncoder(error)), } } @@ -176,45 +202,28 @@ export class OpenAPIHandlerCodecCore { if (hint === 'primitive') { parsedValue = values.at(-1) - } - - else if (hint === 'array') { + } else if (hint === 'array') { parsedValue = values - } - - else if (hint === 'comma-delimited-array') { + } else if (hint === 'comma-delimited-array') { parsedValue = decodeDelimitedArray(values.at(-1), ',') - } - - else if (hint === 'comma-delimited-object') { + } else if (hint === 'comma-delimited-object') { parsedValue = decodeDelimitedObject(values.at(-1), ',') - } - - else if (hint === 'space-delimited-array') { + } else if (hint === 'space-delimited-array') { parsedValue = decodeDelimitedArray(values.at(-1), ' ') - } - - else if (hint === 'space-delimited-object') { + } else if (hint === 'space-delimited-object') { parsedValue = decodeDelimitedObject(values.at(-1), ' ') - } - - else if (hint === 'pipe-delimited-array') { + } else if (hint === 'pipe-delimited-array') { parsedValue = decodeDelimitedArray(values.at(-1), '|') - } - - else if (hint === 'pipe-delimited-object') { + } else if (hint === 'pipe-delimited-object') { parsedValue = decodeDelimitedObject(values.at(-1), '|') - } - - else { + } else { const _expect: 'json' = hint const last = values.at(-1) try { parsedValue = parseEmptyableJSON(last) - } - catch { + } catch { parsedValue = last } } @@ -244,8 +253,7 @@ export class OpenAPIHandlerCodecCore { if (hint === 'comma-delimited-array') { parsed[key] = decodeDelimitedArray(value, ',') - } - else { + } else { const _expect: 'comma-delimited-object' = hint parsed[key] = decodeDelimitedObject(value, ',') @@ -259,7 +267,10 @@ export class OpenAPIHandlerCodecCore { export interface OpenAPIHandlerCodecOptions extends OpenAPIHandlerCodecCoreOptions, OpenAPIMatcherOptions {} -export class OpenAPIHandlerCodec extends OpenAPIHandlerCodecCore implements StandardHandlerCodec { +export class OpenAPIHandlerCodec + extends OpenAPIHandlerCodecCore + implements StandardHandlerCodec +{ private readonly matcher: OpenAPIMatcher constructor(router: AnyRouter, options: OpenAPIHandlerCodecOptions = {}) { @@ -267,7 +278,10 @@ export class OpenAPIHandlerCodec extends OpenAPIHandlerCodecC this.matcher = new OpenAPIMatcher(router, options) } - async resolveProcedure(request: StandardLazyRequest, options: ResolvedStandardHandlerHandleOptions): Promise { + async resolveProcedure( + request: StandardLazyRequest, + options: ResolvedStandardHandlerHandleOptions, + ): Promise { const [pathname] = parseStandardUrl(request.url) const matched = await this.matcher.match(request.method, pathname, options.prefix) @@ -284,20 +298,21 @@ export class OpenAPIHandlerCodec extends OpenAPIHandlerCodecC } } -function isValidDetailedOutput(output: unknown): output is { status?: number, body?: unknown, headers?: object } { +function isValidDetailedOutput( + output: unknown, +): output is { status?: number; body?: unknown; headers?: object } { if (!isTypescriptObject(output)) { return false } - if (Object.keys(output).some(key => key !== 'status' && key !== 'headers' && key !== 'body')) { + 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 > 399 - )) { + if ( + output.status !== undefined && + (typeof output.status !== 'number' || !Number.isInteger(output.status) || output.status > 399) + ) { return false } @@ -316,7 +331,10 @@ function decodeDelimitedArray(value: string | undefined, delimiter: string): und return value.split(delimiter) } -function decodeDelimitedObject(value: string | undefined, delimiter: string): undefined | Record { +function decodeDelimitedObject( + value: string | undefined, + delimiter: string, +): undefined | Record { if (value === undefined) { return undefined } diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts index 0d18715349..873ad38bd3 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts @@ -1,8 +1,9 @@ import { MalformedResponseError, ORPCError, ResponseError } from '@orpc/client' import { oc } from '@orpc/contract' -import { openapi } from '../../meta' -import { OpenAPISerializer } from '../../openapi-serializer' -import { OpenAPILinkCodec } from './openapi-link-codec' + +import { openapi } from '../../meta.ts' +import { OpenAPISerializer } from '../../openapi-serializer.ts' +import { OpenAPILinkCodec } from './openapi-link-codec.ts' const serializer = new OpenAPISerializer() @@ -51,14 +52,19 @@ describe('openAPILinkCodec', () => { }) it('builds a HEAD request with query parameters and no body', async () => { - const codec = new OpenAPILinkCodec({ - check: oc.meta(openapi({ method: 'HEAD', path: '/items/{id}' })), - }, { - url: '/api', - serializer, - }) + const codec = new OpenAPILinkCodec( + { + check: oc.meta(openapi({ method: 'HEAD', path: '/items/{id}' })), + }, + { + url: '/api', + serializer, + }, + ) - const request = await codec.encodeInput({ id: '42', verbose: true }, ['check'], { context: {} }) + const request = await codec.encodeInput({ id: '42', verbose: true }, ['check'], { + context: {}, + }) expect(request.method).toBe('HEAD') expect(request.body).toBeUndefined() @@ -69,36 +75,45 @@ describe('openAPILinkCodec', () => { }) 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 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' }) + 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() @@ -120,9 +135,12 @@ describe('openAPILinkCodec', () => { }) 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 }) + 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).', @@ -132,30 +150,39 @@ describe('openAPILinkCodec', () => { 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 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: {} }) + 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', @@ -167,13 +194,16 @@ describe('openAPILinkCodec', () => { }) 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 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: {} }) @@ -181,25 +211,32 @@ describe('openAPILinkCodec', () => { }) it('serializes non-string header values before merging', async () => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({ inputStructure: 'detailed' })), - }, { - url: '/api', - headers: { 'x-multi': 'base' }, - serializer, - }) + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({ inputStructure: 'detailed' })), + }, + { + url: '/api', + headers: { 'x-multi': 'base' }, + serializer, + }, + ) - const request = await codec.encodeInput({ - headers: { - 'x-number': 42, - 'x-boolean': false, - 'x-date': new Date('2020-01-02T03:04:05.000Z'), - 'x-array': ['a', 1, null, undefined], - 'x-multi': ['b', 'c'], - 'x-null': null, - 'x-undefined': undefined, + const request = await codec.encodeInput( + { + headers: { + 'x-number': 42, + 'x-boolean': false, + 'x-date': new Date('2020-01-02T03:04:05.000Z'), + 'x-array': ['a', 1, null, undefined], + 'x-multi': ['b', 'c'], + 'x-null': null, + 'x-undefined': undefined, + }, }, - }, ['ping'], { context: {} }) + ['ping'], + { context: {} }, + ) expect(request.headers).toEqual({ 'x-number': '42', @@ -213,18 +250,27 @@ describe('openAPILinkCodec', () => { }) 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: {} }) + 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() @@ -232,38 +278,48 @@ describe('openAPILinkCodec', () => { }) it('rejects invalid detailed input shapes', async () => { - const codec = new OpenAPILinkCodec({ - search: oc.meta(openapi({ - method: 'GET', - path: '/users/{id}', - inputStructure: 'detailed', - })), - }, { serializer }) + 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({ 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({ 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):', - ) + 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 }) + 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', @@ -277,57 +333,86 @@ describe('openAPILinkCodec', () => { 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 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: {} }) + 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') + 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 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 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 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') @@ -339,28 +424,37 @@ describe('openAPILinkCodec', () => { }) 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 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 @@ -373,84 +467,111 @@ describe('openAPILinkCodec', () => { }) 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: {} }) + 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: {} }) + 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('omits null and undefined members 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', - emptyObject: 'comma-delimited-object', - }, - })), - }, { url: '/api', serializer }) - - const request = await codec.encodeInput({ - commaArray: [undefined, '1', null], - commaObject: { a: undefined, b: '1', c: null }, - spaceArray: [undefined, '1', null], - spaceObject: { a: undefined, b: '1', c: null }, - pipeArray: [undefined, '1', null], - pipeObject: { a: undefined, b: '1', c: null }, - emptyObject: { a: undefined, b: null }, - }, ['search'], { context: {} }) + 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', + emptyObject: 'comma-delimited-object', + }, + }), + ), + }, + { url: '/api', serializer }, + ) + + const request = await codec.encodeInput( + { + commaArray: [undefined, '1', null], + commaObject: { a: undefined, b: '1', c: null }, + spaceArray: [undefined, '1', null], + spaceObject: { a: undefined, b: '1', c: null }, + pipeArray: [undefined, '1', null], + pipeObject: { a: undefined, b: '1', c: null }, + emptyObject: { a: undefined, b: null }, + }, + ['search'], + { context: {} }, + ) const searchParams = new URL(request.url, 'http://localhost').searchParams @@ -464,9 +585,12 @@ describe('openAPILinkCodec', () => { }) 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 codec = new OpenAPILinkCodec( + { + list: oc.meta(openapi({ method: 'GET' })), + }, + { url: '/api', serializer }, + ) const request = await codec.encodeInput({ page: '2' }, ['list'], { context: {} }) @@ -476,9 +600,12 @@ describe('openAPILinkCodec', () => { }) 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 codec = new OpenAPILinkCodec( + { + list: oc.meta(openapi({ method: 'GET' })), + }, + { url: '/api', serializer }, + ) const request = await codec.encodeInput(undefined, ['list'], { context: {} }) @@ -487,24 +614,33 @@ describe('openAPILinkCodec', () => { }) 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 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 request = await codec.encodeInput( + { + passthrough: 'keep', + optional: null, + list: [undefined, 'value'], + payload: undefined, + }, + ['search'], + { context: {} }, + ) const url = new URL(request.url, 'http://localhost') @@ -516,9 +652,12 @@ describe('openAPILinkCodec', () => { }) 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 codec = new OpenAPILinkCodec( + { + search: oc.meta(openapi({ method: 'GET' })), + }, + { url: '/api?existing=1#frag', serializer }, + ) const request = await codec.encodeInput(undefined, ['search'], { context: {} }) @@ -528,63 +667,98 @@ describe('openAPILinkCodec', () => { 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 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: {} }) + 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 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: {} }) + 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 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: {} }) + 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 primitiveCodec = new OpenAPILinkCodec( + { + item: oc.meta( + openapi({ + path: '/items/{id}', + }), + ), + }, + { url: '/api', serializer }, ) - const arrayCodec = new OpenAPILinkCodec({ - item: oc.meta(openapi({ - path: '/items/{id}', - paramsStyles: { id: 'comma-delimited-array' }, - })), - }, { 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).', @@ -593,82 +767,132 @@ describe('openAPILinkCodec', () => { '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).', + 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).') }) it('throws when path params are or contain dot segments', async () => { - const codec = new OpenAPILinkCodec({ - item: oc.meta(openapi({ - path: '/items/{id}/{ids}/{+rest}', - paramsStyles: { ids: 'comma-delimited-array' }, - })), - }, { url: '/api', serializer }) + const codec = new OpenAPILinkCodec( + { + item: oc.meta( + openapi({ + path: '/items/{id}/{ids}/{+rest}', + paramsStyles: { ids: 'comma-delimited-array' }, + }), + ), + }, + { url: '/api', serializer }, + ) const valid = { id: 'a', ids: ['b'], rest: 'c' } for (const id of ['.', '..']) { - await expect(codec.encodeInput({ ...valid, id }, ['item'], { context: {} })).rejects.toThrow( + await expect( + codec.encodeInput({ ...valid, id }, ['item'], { context: {} }), + ).rejects.toThrow( 'Path param "id" cannot contain "." or ".." segments in call to procedure (item).', ) } for (const ids of [['.'], ['..'], [null, '..']]) { - await expect(codec.encodeInput({ ...valid, ids }, ['item'], { context: {} })).rejects.toThrow( + await expect( + codec.encodeInput({ ...valid, ids }, ['item'], { context: {} }), + ).rejects.toThrow( 'Path param "ids" cannot contain "." or ".." segments in call to procedure (item).', ) } - for (const rest of ['.', '..', './a', '../a', 'a/.', 'a/..', 'a/./b', 'a/../../admin', '/../a']) { - await expect(codec.encodeInput({ ...valid, rest }, ['item'], { context: {} })).rejects.toThrow( + for (const rest of [ + '.', + '..', + './a', + '../a', + 'a/.', + 'a/..', + 'a/./b', + 'a/../../admin', + '/../a', + ]) { + await expect( + codec.encodeInput({ ...valid, rest }, ['item'], { context: {} }), + ).rejects.toThrow( 'Path param "rest" cannot contain "." or ".." segments in call to procedure (item).', ) } }) it('allows path params that only contain dots within a segment', async () => { - const codec = new OpenAPILinkCodec({ - item: oc.meta(openapi({ - path: '/items/{id}/{+rest}', - })), - }, { url: '/api', serializer }) + const codec = new OpenAPILinkCodec( + { + item: oc.meta( + openapi({ + path: '/items/{id}/{+rest}', + }), + ), + }, + { url: '/api', serializer }, + ) - const request = await codec.encodeInput({ id: '...', rest: '.well-known/a..b/v1.2/.../%2e%2e' }, ['item'], { context: {} }) + const request = await codec.encodeInput( + { id: '...', rest: '.well-known/a..b/v1.2/.../%2e%2e' }, + ['item'], + { context: {} }, + ) expect(request.url).toBe('/api/items/.../.well-known/a..b/v1.2/.../%252e%252e') }) it('encodes a leading slash in catch-all path params so the url is never protocol-relative', async () => { - const rootCodec = new OpenAPILinkCodec({ - file: oc.meta(openapi({ - path: '/{+path}', - })), - }, { serializer }) + const rootCodec = new OpenAPILinkCodec( + { + file: oc.meta( + openapi({ + path: '/{+path}', + }), + ), + }, + { serializer }, + ) - for (const [path, url] of [['/evil.example/x', '/%2Fevil.example/x'], ['//evil.example/x', '/%2F/evil.example/x']]) { + for (const [path, url] of [ + ['/evil.example/x', '/%2Fevil.example/x'], + ['//evil.example/x', '/%2F/evil.example/x'], + ]) { const request = await rootCodec.encodeInput({ path }, ['file'], { context: {} }) expect(request.url).toBe(url) } - const nestedCodec = new OpenAPILinkCodec({ - item: oc.meta(openapi({ - path: '/items/{id}/{+rest}', - })), - }, { url: '/api', serializer }) + const nestedCodec = new OpenAPILinkCodec( + { + item: oc.meta( + openapi({ + path: '/items/{id}/{+rest}', + }), + ), + }, + { url: '/api', serializer }, + ) - const request = await nestedCodec.encodeInput({ id: 'a', rest: '/x' }, ['item'], { context: {} }) + const request = await nestedCodec.encodeInput({ id: 'a', rest: '/x' }, ['item'], { + context: {}, + }) expect(request.url).toBe('/api/items/a/%2Fx') }) @@ -678,55 +902,75 @@ describe('openAPILinkCodec', () => { const formDataSerializer = new OpenAPISerializer({ serialize: { asFormData: true } }) it('does not affect path param encoding', async () => { - const codec = new OpenAPILinkCodec({ - item: oc.meta(openapi({ - path: '/items/{id}/{ids}/{filter}/{+rest}', - paramsStyles: { ids: 'comma-delimited-array', filter: 'comma-delimited-object' }, - })), - }, { url: '/api', serializer: formDataSerializer }) - - const request = await codec.encodeInput({ - id: '42', - ids: [1, 2n], - filter: { color: 'red', at: new Date('2023-01-01T00:00:00.000Z') }, - rest: 'docs/v1', - }, ['item'], { context: {} }) - - expect(request.url).toBe('/api/items/42/1,2/color,red,at,2023-01-01T00%3A00%3A00.000Z/docs/v1') + const codec = new OpenAPILinkCodec( + { + item: oc.meta( + openapi({ + path: '/items/{id}/{ids}/{filter}/{+rest}', + paramsStyles: { ids: 'comma-delimited-array', filter: 'comma-delimited-object' }, + }), + ), + }, + { url: '/api', serializer: formDataSerializer }, + ) + + const request = await codec.encodeInput( + { + id: '42', + ids: [1, 2n], + filter: { color: 'red', at: new Date('2023-01-01T00:00:00.000Z') }, + rest: 'docs/v1', + }, + ['item'], + { context: {} }, + ) + + expect(request.url).toBe( + '/api/items/42/1,2/color,red,at,2023-01-01T00%3A00%3A00.000Z/docs/v1', + ) }) it('does not affect styled query values', async () => { - const codec = new OpenAPILinkCodec({ - search: oc.meta(openapi({ - method: 'GET', - queryStyles: { - p: 'primitive', - a: 'array', - j: 'json', - ca: 'comma-delimited-array', - co: 'comma-delimited-object', - sa: 'space-delimited-array', - so: 'space-delimited-object', - pa: 'pipe-delimited-array', - po: 'pipe-delimited-object', - mismatch: 'comma-delimited-array', - }, - })), - }, { url: '/api', serializer: formDataSerializer }) - - const request = await codec.encodeInput({ - p: 'x', - a: ['1', 2], - j: { k: 'v', n: [1] }, - ca: ['a', 'b'], - co: { k: 'v' }, - sa: ['a', 'b'], - so: { k: 'v' }, - pa: ['a', 'b'], - po: { k: 'v' }, - mismatch: 'scalar', - rest: { nested: 'value' }, - }, ['search'], { context: {} }) + const codec = new OpenAPILinkCodec( + { + search: oc.meta( + openapi({ + method: 'GET', + queryStyles: { + p: 'primitive', + a: 'array', + j: 'json', + ca: 'comma-delimited-array', + co: 'comma-delimited-object', + sa: 'space-delimited-array', + so: 'space-delimited-object', + pa: 'pipe-delimited-array', + po: 'pipe-delimited-object', + mismatch: 'comma-delimited-array', + }, + }), + ), + }, + { url: '/api', serializer: formDataSerializer }, + ) + + const request = await codec.encodeInput( + { + p: 'x', + a: ['1', 2], + j: { k: 'v', n: [1] }, + ca: ['a', 'b'], + co: { k: 'v' }, + sa: ['a', 'b'], + so: { k: 'v' }, + pa: ['a', 'b'], + po: { k: 'v' }, + mismatch: 'scalar', + rest: { nested: 'value' }, + }, + ['search'], + { context: {} }, + ) const url = new URL(request.url, 'http://localhost') @@ -744,14 +988,21 @@ describe('openAPILinkCodec', () => { }) it('still applies to the request body', async () => { - const codec = new OpenAPILinkCodec({ - create: oc.meta(openapi({ - method: 'POST', - path: '/items/{id}', - })), - }, { url: '/api', serializer: formDataSerializer }) + const codec = new OpenAPILinkCodec( + { + create: oc.meta( + openapi({ + method: 'POST', + path: '/items/{id}', + }), + ), + }, + { url: '/api', serializer: formDataSerializer }, + ) - const request = await codec.encodeInput({ id: '42', name: 'item' }, ['create'], { context: {} }) + const request = await codec.encodeInput({ id: '42', name: 'item' }, ['create'], { + context: {}, + }) expect(request.url).toBe('/api/items/42') expect(request.body).toBeInstanceOf(FormData) @@ -761,12 +1012,15 @@ describe('openAPILinkCodec', () => { 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 codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({})), + }, + { + headers: new Headers({ 'x-token': 'abc' }), + serializer, + }, + ) const request = await codec.encodeInput('input', ['ping'], { context: {} }) @@ -774,9 +1028,12 @@ describe('openAPILinkCodec', () => { }) it('rejects unresolved procedure paths', async () => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({})), - }, { serializer }) + 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)', @@ -787,16 +1044,23 @@ describe('openAPILinkCodec', () => { describe('.decodeResponse', () => { it('returns compact output bodies directly', async () => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({ outputStructure: 'compact', responseBodyHint: 'json' })), - }, { serializer }) + 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: {} }) + const result = await codec.decodeResponse( + { + status: 201, + headers: { 'x-trace': '1' }, + resolveBody, + }, + ['ping'], + { context: {} }, + ) expect(result).toEqual({ kind: 'output', @@ -808,16 +1072,23 @@ describe('openAPILinkCodec', () => { }) it('returns detailed output bodies with status and headers', async () => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({ outputStructure: 'detailed', responseBodyHint: 'json' })), - }, { serializer }) + 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: {} }) + const result = await codec.decodeResponse( + { + status: 202, + headers: { 'x-trace': '1' }, + resolveBody, + }, + ['ping'], + { context: {} }, + ) expect(result).toEqual({ kind: 'output', @@ -833,16 +1104,23 @@ describe('openAPILinkCodec', () => { }) it('defaults successful responses to compact output when outputStructure is omitted', async () => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({})), - }, { serializer }) + 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: {} }) + const result = await codec.decodeResponse( + { + status: 200, + headers: { 'x-trace': '1' }, + resolveBody, + }, + ['ping'], + { context: {} }, + ) expect(result).toEqual({ kind: 'output', @@ -854,20 +1132,27 @@ describe('openAPILinkCodec', () => { }) it('decodes ORPC error payloads from unsuccessful responses', async () => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({})), - }, { serializer }) + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({})), + }, + { serializer }, + ) const error = new ORPCError('NOT_FOUND', { message: 'Missing', data: { id: '42' }, }) - const result: any = await codec.decodeResponse({ - status: 404, - headers: { 'x-request-id': 'abc' }, - resolveBody: async () => error.toJSON(), - }, ['ping'], { context: {} }) + const result: any = await codec.decodeResponse( + { + status: 404, + headers: { 'x-request-id': 'abc' }, + resolveBody: async () => error.toJSON(), + }, + ['ping'], + { context: {} }, + ) expectORPCErrorResult(result, 'NOT_FOUND', { message: 'Missing', @@ -877,22 +1162,33 @@ describe('openAPILinkCodec', () => { expect(result.error.cause).toBeInstanceOf(ResponseError) expect(result.error.cause).not.toBeInstanceOf(MalformedResponseError) expect(result.error.cause.message).toBe('Received a 404 response.') - expect(result.error.cause.response).toEqual({ status: 404, headers: { 'x-request-id': 'abc' }, body: error.toJSON() }) + expect(result.error.cause.response).toEqual({ + status: 404, + headers: { 'x-request-id': 'abc' }, + body: error.toJSON(), + }) }) it('ignores responseBodyHint on unsuccessful responses', async () => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({ responseBodyHint: 'file' })), - }, { serializer }) + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({ responseBodyHint: 'file' })), + }, + { serializer }, + ) const error = new ORPCError('NOT_FOUND', { message: 'Missing' }) const resolveBody = vi.fn(async () => error.toJSON()) - const result = await codec.decodeResponse({ - status: 404, - headers: {}, - resolveBody, - }, ['ping'], { context: {} }) + const result = await codec.decodeResponse( + { + status: 404, + headers: {}, + resolveBody, + }, + ['ping'], + { context: {} }, + ) expectORPCErrorResult(result, 'NOT_FOUND', { message: 'Missing' }) expect(resolveBody).toHaveBeenCalledTimes(1) @@ -902,12 +1198,15 @@ describe('openAPILinkCodec', () => { it('uses a custom error decoder', async () => { const customError = new ORPCError('BAD_GATEWAY', { data: 'custom' }) const errorResponseBodyDecoder = vi.fn(() => customError) - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({})), - }, { - serializer, - errorResponseBodyDecoder, - }) + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({})), + }, + { + serializer, + errorResponseBodyDecoder, + }, + ) // an ORPC error body, to check the default decoding is not used as a fallback const response = { @@ -920,102 +1219,165 @@ describe('openAPILinkCodec', () => { expect(result).toEqual({ kind: 'error', error: customError }) expect(errorResponseBodyDecoder).toHaveBeenCalledOnce() - expect(errorResponseBodyDecoder).toHaveBeenCalledWith(new ORPCError('NOT_FOUND').toJSON(), response, expect.any(Function)) + expect(errorResponseBodyDecoder).toHaveBeenCalledWith( + new ORPCError('NOT_FOUND').toJSON(), + response, + expect.any(Function), + ) }) it('passes the default decoding to a custom error decoder as defaultError', async () => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({})), - }, { - serializer, - errorResponseBodyDecoder: (_body, _response, defaultError) => defaultError(), - }) + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({})), + }, + { + serializer, + errorResponseBodyDecoder: (_body, _response, defaultError) => defaultError(), + }, + ) - const result: any = await codec.decodeResponse({ - status: 404, - headers: {}, - resolveBody: async () => new ORPCError('NOT_FOUND').toJSON(), - }, ['ping'], { context: {} }) + const result: any = await codec.decodeResponse( + { + status: 404, + headers: {}, + resolveBody: async () => new ORPCError('NOT_FOUND').toJSON(), + }, + ['ping'], + { context: {} }, + ) expectORPCErrorResult(result, 'NOT_FOUND') expect(result.error.cause).toBeInstanceOf(ResponseError) - const malformed: any = await codec.decodeResponse({ - status: 503, - headers: {}, - resolveBody: async () => ({ detail: 'service unavailable' }), - }, ['ping'], { context: {} }) + const malformed: any = await codec.decodeResponse( + { + status: 503, + headers: {}, + resolveBody: async () => ({ detail: 'service unavailable' }), + }, + ['ping'], + { context: {} }, + ) expect(malformed.error).toBeInstanceOf(MalformedResponseError) }) it.each([ ['a matching error', 404, 'application/problem+json', 'json'], - ['an error with different case and parameters', 404, 'Application/Problem+JSON; charset=utf-8', 'json'], + [ + 'an error with different case and parameters', + 404, + 'Application/Problem+JSON; charset=utf-8', + 'json', + ], ['an error with another type', 502, 'text/html', undefined], ['a successful response', 200, 'application/problem+json', 'file'], - ] as const)('resolves the body hint of %s with errorResponseContentType', async (_, status, contentType, hint) => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({ responseBodyHint: 'file' })), - }, { serializer, errorResponseContentType: 'application/problem+json; charset=utf-8' }) + ] as const)( + 'resolves the body hint of %s with errorResponseContentType', + async (_, status, contentType, hint) => { + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({ responseBodyHint: 'file' })), + }, + { serializer, errorResponseContentType: 'application/problem+json; charset=utf-8' }, + ) - const resolveBody = vi.fn(async () => new ORPCError('NOT_FOUND').toJSON()) - await codec.decodeResponse({ status, headers: { 'content-type': contentType }, resolveBody }, ['ping'], { context: {} }) + const resolveBody = vi.fn(async () => new ORPCError('NOT_FOUND').toJSON()) + await codec.decodeResponse( + { status, headers: { 'content-type': contentType }, resolveBody }, + ['ping'], + { context: {} }, + ) - expect(resolveBody).toHaveBeenCalledWith(hint) - }) + expect(resolveBody).toHaveBeenCalledWith(hint) + }, + ) it('returns MalformedResponseError on unknown error payloads', async () => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({})), - }, { serializer }) + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({})), + }, + { serializer }, + ) - const result: any = await codec.decodeResponse({ - status: 503, - headers: {}, - resolveBody: async () => ({ detail: 'service unavailable' }), - }, ['ping'], { context: {} }) + const result: any = await codec.decodeResponse( + { + status: 503, + headers: {}, + resolveBody: async () => ({ detail: 'service unavailable' }), + }, + ['ping'], + { context: {} }, + ) expect(result.kind).toBe('error') const error = result.error expect(error).toBeInstanceOf(MalformedResponseError) expect(error.message).toBe('Service Unavailable') - expect(error.response).toEqual({ status: 503, headers: {}, body: { detail: 'service unavailable' } }) + expect(error.response).toEqual({ + status: 503, + headers: {}, + body: { detail: 'service unavailable' }, + }) }) it('rethrows the original error 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') + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({})), }, - }, ['ping'], { context: {} })).rejects.toThrow('network error') + { serializer }, + ) + + await expect( + codec.decodeResponse( + { + status: 200, + headers: {}, + resolveBody: async () => { + throw new Error('network error') + }, + }, + ['ping'], + { context: {} }, + ), + ).rejects.toThrow('network error') }) it('throws when the deserialized response body has an invalid format', async () => { const badSerializer: any = { + // oxlint-disable-next-line typescript/no-misused-spread ...serializer, deserialize: () => { throw new Error('bad format') }, } - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({})), - }, { - serializer: badSerializer, - }) + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({})), + }, + { + serializer: badSerializer, + }, + ) - const error: any = await codec.decodeResponse({ - status: 200, - headers: {}, - resolveBody: async () => 'raw', - }, ['ping'], { context: {} }).then(() => null, e => e) + const error: any = await codec + .decodeResponse( + { + status: 200, + headers: {}, + resolveBody: async () => 'raw', + }, + ['ping'], + { context: {} }, + ) + .then( + () => null, + (e) => e, + ) expect(error).toBeInstanceOf(MalformedResponseError) expect(error.message).toBe('Invalid OpenAPI response format.') @@ -1024,30 +1386,44 @@ describe('openAPILinkCodec', () => { }) 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)', + 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)') }) it.each([100, 199, 200, 204, 301, 302, 399])('treats status %i as success', async (status) => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({ outputStructure: 'compact', responseBodyHint: 'json' })), - }, { serializer }) + 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, - headers: {}, - resolveBody, - }, ['ping'], { context: {} }) + const result = await codec.decodeResponse( + { + status, + headers: {}, + resolveBody, + }, + ['ping'], + { context: {} }, + ) expect(result).toEqual({ kind: 'output', @@ -1056,17 +1432,24 @@ describe('openAPILinkCodec', () => { }) it.each([400, 401, 403, 404, 500, 599, 600])('treats status %i as error', async (status) => { - const codec = new OpenAPILinkCodec({ - ping: oc.meta(openapi({})), - }, { serializer }) + const codec = new OpenAPILinkCodec( + { + ping: oc.meta(openapi({})), + }, + { serializer }, + ) const error = new ORPCError('BAD_REQUEST') - const result = await codec.decodeResponse({ - status, - headers: {}, - resolveBody: async () => error.toJSON(), - }, ['ping'], { context: {} }) + const result = await codec.decodeResponse( + { + status, + headers: {}, + resolveBody: async () => error.toJSON(), + }, + ['ping'], + { context: {} }, + ) expect(result.kind).toBe('error') }) diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.ts index 0c4c3f6fdc..7e2eac3d5b 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.ts @@ -1,24 +1,46 @@ import type { ClientContext, ClientOptions } from '@orpc/client' +import { + createORPCErrorFromJson, + isORPCErrorJson, + MalformedResponseError, + ResponseError, +} from '@orpc/client' import type { StandardLinkCodec, StandardLinkCodecDecodedResponse } from '@orpc/client/standard' import type { AnyProcedureContract, RouterContract } from '@orpc/contract' -import type { Promisable, Public, Value } from '@orpc/shared' -import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardUrl } from '@standard-server/core' -import type { OpenAPIMeta } from '../../meta' -import { createORPCErrorFromJson, isORPCErrorJson, MalformedResponseError, ResponseError } from '@orpc/client' import { getRouterContract, ProcedureContract } from '@orpc/contract' import { unlazy } from '@orpc/server' -import { isTypescriptObject, mergeHttpPath, pathToHttpPath, safeEncodeURIComponent, stringifyJSON, value } from '@orpc/shared' -import { flattenStandardHeader, mergeStandardHeaders, parseStandardUrl } from '@standard-server/core' +import type { Promisable, Public, Value } from '@orpc/shared' +import { + isTypescriptObject, + mergeHttpPath, + pathToHttpPath, + safeEncodeURIComponent, + stringifyJSON, + value, +} from '@orpc/shared' +import type { + StandardHeaders, + StandardLazyResponse, + StandardRequest, + StandardUrl, +} from '@standard-server/core' +import { + flattenStandardHeader, + mergeStandardHeaders, + parseStandardUrl, +} from '@standard-server/core' import { toStandardHeaders } from '@standard-server/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, isBodylessMethod } from '../../utils' -import { serializeHeaders } from './utils' +} from '../../constants.ts' +import type { OpenAPIMeta } from '../../meta.ts' +import { getOpenAPIMeta } from '../../meta.ts' +import { OpenAPISerializer } from '../../openapi-serializer.ts' +import { getDynamicPathParams, isBodylessMethod } from '../../utils.ts' +import { serializeHeaders } from './utils.ts' export interface OpenAPILinkCodecOptions { /** @@ -27,12 +49,19 @@ export interface OpenAPILinkCodecOptions { * @example '/api' * @default '/' */ - url?: Value, [options: ClientOptions, path: string[], input: unknown]> | undefined + url?: + | Value, [options: ClientOptions, path: string[], input: unknown]> + | undefined /** * Inject headers into the request. */ - headers?: Value, [options: ClientOptions, path: string[], input: unknown]> | undefined + headers?: + | Value< + Promisable, + [options: ClientOptions, path: string[], input: unknown] + > + | undefined /** * Override the default OpenAPI serializer. @@ -47,11 +76,13 @@ export interface OpenAPILinkCodecOptions { * * @default ((body, response, defaultError) => defaultError()) */ - errorResponseBodyDecoder?: (( - deserializedBody: unknown, - response: StandardLazyResponse, - defaultError: () => Error, - ) => Error) | undefined + errorResponseBodyDecoder?: + | (( + deserializedBody: unknown, + response: StandardLazyResponse, + defaultError: () => Error, + ) => Error) + | undefined /** * Parse error responses with this `content-type` as JSON, ignoring case and parameters such as `charset`. @@ -69,7 +100,10 @@ export class OpenAPILinkCodec implements StandardLinkCo private readonly baseUrl: Exclude['url'], undefined> private readonly headers: Exclude['headers'], undefined> private readonly serializer: Exclude['serializer'], undefined> - private readonly errorResponseBodyDecoder: Exclude['errorResponseBodyDecoder'], undefined> + private readonly errorResponseBodyDecoder: Exclude< + OpenAPILinkCodecOptions['errorResponseBodyDecoder'], + undefined + > private readonly errorResponseMediaType: string | undefined constructor( @@ -79,11 +113,16 @@ export class OpenAPILinkCodec implements StandardLinkCo this.baseUrl = options.url ?? '/' this.headers = options.headers ?? {} this.serializer = options.serializer ?? new OpenAPISerializer() - this.errorResponseBodyDecoder = options.errorResponseBodyDecoder ?? ((_body, _response, defaultError) => defaultError()) + this.errorResponseBodyDecoder = + options.errorResponseBodyDecoder ?? ((_body, _response, defaultError) => defaultError()) this.errorResponseMediaType = toMediaType(options.errorResponseContentType) } - async encodeInput(input: unknown, path: string[], options: ClientOptions): Promise { + 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 }) @@ -109,7 +148,7 @@ export class OpenAPILinkCodec implements StandardLinkCo if (dynamicParams?.length) { if (!isTypescriptObject(input)) { throw new TypeError( - `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('.')}).`, + `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('.')}).`, ) } @@ -117,8 +156,14 @@ export class OpenAPILinkCodec implements StandardLinkCo 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}` + 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] } @@ -169,15 +214,21 @@ export class OpenAPILinkCodec implements StandardLinkCo if (dynamicParams?.length) { if (!input?.params) { throw new TypeError( - `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('.')}).`, + `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}` + 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}` } } @@ -211,7 +262,7 @@ export class OpenAPILinkCodec implements StandardLinkCo private encodePathParam( val: unknown, - param: { parameterName: string, allowsSlash: boolean, segment: string }, + param: { parameterName: string; allowsSlash: boolean; segment: string }, style: Exclude[string], path: string[], ): string { @@ -219,38 +270,45 @@ export class OpenAPILinkCodec implements StandardLinkCo if (style === 'comma-delimited-array' && Array.isArray(val)) { encoded = val - .map(val => this.serializeURLValue(val)) - .filter(val => val !== undefined && val !== null) - .map(val => safeEncodeURIComponent(String(val))) + .map((val) => this.serializeURLValue(val)) + .filter((val) => val !== undefined && val !== null) + // oxlint-disable-next-line typescript/no-base-to-string + .map((val) => safeEncodeURIComponent(String(val))) .join(',') - } - else if (style === 'comma-delimited-object' && isTypescriptObject(val)) { + } else if (style === 'comma-delimited-object' && isTypescriptObject(val)) { encoded = Object.entries(val) .map(([key, val]) => [key, this.serializeURLValue(val)]) .filter(([, val]) => val !== undefined && val !== null) - .map(([key, val]) => `${safeEncodeURIComponent(String(key))},${safeEncodeURIComponent(String(val))}`) + .map( + ([key, val]) => + `${safeEncodeURIComponent(String(key))},${safeEncodeURIComponent(String(val))}`, + ) .join(',') - } - else { + } else { const serialized = this.serializeURLValue(val) if (serialized !== undefined && serialized !== null) { if (param.allowsSlash) { + // oxlint-disable-next-line typescript/no-base-to-string encoded = String(serialized).split('/').map(safeEncodeURIComponent).join('/') - } - else { + } else { + // oxlint-disable-next-line typescript/no-base-to-string encoded = safeEncodeURIComponent(String(serialized)) } } } if (!encoded) { - throw new TypeError(`Path param "${param.parameterName}" cannot be empty in call to procedure (${path.join('.')}).`) + throw new TypeError( + `Path param "${param.parameterName}" cannot be empty in call to procedure (${path.join('.')}).`, + ) } // URL parsing (e.g. in fetch) resolves "." and ".." segments, which would send the request to a different endpoint if (DOT_SEGMENT_REGEX.test(encoded)) { - throw new TypeError(`Path param "${param.parameterName}" cannot contain "." or ".." segments in call to procedure (${path.join('.')}).`) + throw new TypeError( + `Path param "${param.parameterName}" cannot contain "." or ".." segments in call to procedure (${path.join('.')}).`, + ) } if (encoded.startsWith('/')) { @@ -268,7 +326,10 @@ export class OpenAPILinkCodec implements StandardLinkCo return value === undefined ? undefined : this.serializer.serializeJson(value).json } - private serializeQueryString(data: unknown, queryStyles: OpenAPIMeta['queryStyles']): string | undefined { + private serializeQueryString( + data: unknown, + queryStyles: OpenAPIMeta['queryStyles'], + ): string | undefined { if (!queryStyles || !isTypescriptObject(data)) { return toURLSearchParams( this.serializer.serialize(data, { asFormData: true }) as FormData, @@ -289,41 +350,35 @@ export class OpenAPILinkCodec implements StandardLinkCo if (style === 'primitive') { const serialized = this.serializeURLValue(value) if (serialized !== undefined && serialized !== null) { + // oxlint-disable-next-line typescript/no-base-to-string query += `&${encodeURLSearchParamComponent(key)}=${encodeURLSearchParamComponent(String(serialized))}` } - } - - else if (style === 'array' && Array.isArray(value)) { + } else if (style === 'array' && Array.isArray(value)) { const encodedKey = encodeURLSearchParamComponent(key) value.forEach((v) => { const s = this.serializeURLValue(v) if (s !== undefined && s !== null) { + // oxlint-disable-next-line typescript/no-base-to-string query += `&${encodedKey}=${encodeURLSearchParamComponent(String(s))}` } }) - } - - else if (style === 'json') { + } else if (style === 'json') { const serialized = this.serializeURLValue(value) if (serialized !== undefined) { query += `&${encodeURLSearchParamComponent(key)}=${encodeURLSearchParamComponent(stringifyJSON(serialized))}` } - } - - else if (style === 'comma-delimited-array' && Array.isArray(value)) { + } else if (style === 'comma-delimited-array' && Array.isArray(value)) { const encodedValue = encodeDelimitedArray( - value.map(v => this.serializeURLValue(v)), + value.map((v) => this.serializeURLValue(v)), ',', ) if (encodedValue !== undefined) { query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` } - } - - else if (style === 'comma-delimited-object' && isTypescriptObject(value)) { + } else if (style === 'comma-delimited-object' && isTypescriptObject(value)) { const encodedValue = encodeDelimitedObject( Object.entries(value).map(([key, value]) => [key, this.serializeURLValue(value)]), ',', @@ -332,20 +387,16 @@ export class OpenAPILinkCodec implements StandardLinkCo if (encodedValue !== undefined) { query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` } - } - - else if (style === 'pipe-delimited-array' && Array.isArray(value)) { + } else if (style === 'pipe-delimited-array' && Array.isArray(value)) { const encodedValue = encodeDelimitedArray( - value.map(v => this.serializeURLValue(v)), + value.map((v) => this.serializeURLValue(v)), '%7C' /* '/' */, ) if (encodedValue !== undefined) { query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` } - } - - else if (style === 'pipe-delimited-object' && isTypescriptObject(value)) { + } else if (style === 'pipe-delimited-object' && isTypescriptObject(value)) { const encodedValue = encodeDelimitedObject( Object.entries(value).map(([key, value]) => [key, this.serializeURLValue(value)]), '%7C' /* '/' */, @@ -354,20 +405,16 @@ export class OpenAPILinkCodec implements StandardLinkCo if (encodedValue !== undefined) { query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` } - } - - else if (style === 'space-delimited-array' && Array.isArray(value)) { + } else if (style === 'space-delimited-array' && Array.isArray(value)) { const encodedValue = encodeDelimitedArray( - value.map(v => this.serializeURLValue(v)), + value.map((v) => this.serializeURLValue(v)), '%20' /* ' ' */, ) if (encodedValue !== undefined) { query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` } - } - - else if (style === 'space-delimited-object' && isTypescriptObject(value)) { + } else if (style === 'space-delimited-object' && isTypescriptObject(value)) { const encodedValue = encodeDelimitedObject( Object.entries(value).map(([key, value]) => [key, this.serializeURLValue(value)]), '%20' /* ' ' */, @@ -376,11 +423,10 @@ export class OpenAPILinkCodec implements StandardLinkCo if (encodedValue !== undefined) { query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` } - } - - else { + } else { const serialized = this.serializeURLValue(value) if (serialized !== undefined && serialized !== null) { + // oxlint-disable-next-line typescript/no-base-to-string query += `&${encodeURLSearchParamComponent(key)}=${encodeURLSearchParamComponent(String(serialized))}` } } @@ -405,19 +451,20 @@ export class OpenAPILinkCodec implements StandardLinkCo const procedure = await this.resolveProcedure(path) const meta = getOpenAPIMeta(procedure) - const errorBodyHint = !isOk - && this.errorResponseMediaType !== undefined - && toMediaType(flattenStandardHeader(response.headers['content-type'])) === this.errorResponseMediaType - ? 'json' - : undefined + const errorBodyHint = + !isOk && + this.errorResponseMediaType !== undefined && + toMediaType(flattenStandardHeader(response.headers['content-type'])) === + this.errorResponseMediaType + ? 'json' + : undefined const body = await response.resolveBody(isOk ? meta?.responseBodyHint : errorBodyHint) const deserialized = await (async () => { try { return this.serializer.deserialize(body) - } - catch (error) { + } catch (error) { throw new MalformedResponseError({ message: 'Invalid OpenAPI response format.', response: { status: response.status, headers: response.headers, body }, @@ -430,14 +477,21 @@ export class OpenAPILinkCodec implements StandardLinkCo const defaultError = (): Error => { if (isORPCErrorJson(deserialized)) { return createORPCErrorFromJson(deserialized, { - cause: new ResponseError({ response: { status: response.status, headers: response.headers, body } }), + cause: new ResponseError({ + response: { status: response.status, headers: response.headers, body }, + }), }) } - return new MalformedResponseError({ response: { headers: response.headers, status: response.status, body } }) + return new MalformedResponseError({ + response: { headers: response.headers, status: response.status, body }, + }) } - return { kind: 'error', error: this.errorResponseBodyDecoder(deserialized, response, defaultError) } + return { + kind: 'error', + error: this.errorResponseBodyDecoder(deserialized, response, defaultError), + } } const outputStructure = meta?.outputStructure ?? DEFAULT_OPENAPI_OUTPUT_STRUCTURE @@ -465,7 +519,10 @@ export class OpenAPILinkCodec implements StandardLinkCo } } -function combineSearch(baseSearch: `?${string}` | undefined, additionalSearch: string | undefined): `?${string}` | undefined { +function combineSearch( + baseSearch: `?${string}` | undefined, + additionalSearch: string | undefined, +): `?${string}` | undefined { if (!baseSearch && !additionalSearch) { return undefined } @@ -501,9 +558,14 @@ function toResolvedStandardHeaders(headers: Headers | StandardHeaders): Standard return headers as StandardHeaders } -function isValidDetailedInput( - input: unknown, -): input is undefined | { params?: Record, query?: Record, headers?: object, body?: unknown } { +function isValidDetailedInput(input: unknown): input is + | undefined + | { + params?: Record + query?: Record + headers?: object + body?: unknown + } { if (!isTypescriptObject(input)) { return input === undefined } @@ -534,13 +596,17 @@ function encodeURLSearchParamComponent(value: string): string { function toURLSearchParams(form: FormData): URLSearchParams { const params = new URLSearchParams() for (const [key, value] of form) { + // oxlint-disable-next-line typescript/no-base-to-string 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) +function encodeDelimitedArray( + serializedValues: unknown[], + encodedDelimiter: string, +): string | undefined { + const strings = serializedValues.filter((v) => v !== null && v !== undefined).map(String) if (!strings.length) { return undefined @@ -549,7 +615,10 @@ function encodeDelimitedArray(serializedValues: unknown[], encodedDelimiter: str return strings.map(encodeURLSearchParamComponent).join(encodedDelimiter) } -function encodeDelimitedObject(entries: [string, unknown][], encodedDelimiter: string): string | undefined { +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][] @@ -560,7 +629,8 @@ function encodeDelimitedObject(entries: [string, unknown][], encodedDelimiter: s return strings .map( - ([key, value]) => `${encodeURLSearchParamComponent(key)}${encodedDelimiter}${encodeURLSearchParamComponent(value)}`, + ([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 f98c771766..b323231126 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.test.ts @@ -1,8 +1,9 @@ import { oc } from '@orpc/contract' import { call, implement, os } from '@orpc/server' import { z } from 'zod' -import { getOpenAPIMeta, openapi } from '../../meta' -import { OpenAPIMatcher } from './openapi-matcher' + +import { getOpenAPIMeta, openapi } from '../../meta.ts' +import { OpenAPIMatcher } from './openapi-matcher.ts' beforeEach(() => { vi.clearAllMocks() @@ -33,14 +34,14 @@ describe('openAPIMatcher', () => { params: { value: 'dinwwwh/' }, }) - await expect(matcher.match('POST', '/nested/echo/dinwwwh%2F', undefined)).resolves.toBeUndefined() + await expect( + matcher.match('POST', '/nested/echo/dinwwwh%2F', undefined), + ).resolves.toBeUndefined() }) 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 echo = os.meta(openapi({ method: 'GET', path: '/echo/' })).handler(() => 'echo') const matcher = new OpenAPIMatcher({ ping, echo }) @@ -64,9 +65,7 @@ describe('openAPIMatcher', () => { }) it('decodes catch-all params, keeps their empty segments, and trims trailing slashes', async () => { - const files = os - .meta(openapi({ method: 'GET', path: '/files/{+path}' })) - .handler(() => 'ok') + const files = os.meta(openapi({ method: 'GET', path: '/files/{+path}' })).handler(() => 'ok') const matcher = new OpenAPIMatcher({ files }) @@ -76,7 +75,9 @@ describe('openAPIMatcher', () => { params: { path: 'a/b/c/d' }, }) - await expect(matcher.match('GET', '/files/https%3A//example.com/a', undefined)).resolves.toEqual({ + await expect( + matcher.match('GET', '/files/https%3A//example.com/a', undefined), + ).resolves.toEqual({ path: ['files'], procedure: files, params: { path: 'https://example.com/a' }, @@ -90,9 +91,7 @@ describe('openAPIMatcher', () => { }) it('applies OpenAPI prefixes to generated and explicit routes', async () => { - const ping = os - .meta(openapi({ prefix: '/api' })) - .handler(() => 'pong') + const ping = os.meta(openapi({ prefix: '/api' })).handler(() => 'pong') const echo = os .meta(openapi({ method: 'GET', prefix: '/api/v1', path: '/echo/{value}' })) @@ -121,12 +120,15 @@ describe('openAPIMatcher', () => { const secret = os.handler(() => 'hidden') const filter = vi.fn((_procedure: unknown, path: string[]) => !path.includes('secret')) - const matcher = new OpenAPIMatcher({ - ping, - internal: { secret }, - }, { - filter, - }) + const matcher = new OpenAPIMatcher( + { + ping, + internal: { secret }, + }, + { + filter, + }, + ) await expect(matcher.match('POST', '/ping', undefined)).resolves.toEqual({ path: ['ping'], @@ -168,9 +170,15 @@ describe('openAPIMatcher', () => { }) it('supports param names that are not identifiers, and repeated names', async () => { - const hyphen = os.meta(openapi({ method: 'GET', path: '/users/{user-id}' })).handler(() => 'ok') - const digit = os.meta(openapi({ method: 'GET', path: '/digits/{0}/{1st}' })).handler(() => 'ok') - const repeated = os.meta(openapi({ method: 'GET', path: '/repeated/{id}/{id}' })).handler(() => 'ok') + const hyphen = os + .meta(openapi({ method: 'GET', path: '/users/{user-id}' })) + .handler(() => 'ok') + const digit = os + .meta(openapi({ method: 'GET', path: '/digits/{0}/{1st}' })) + .handler(() => 'ok') + const repeated = os + .meta(openapi({ method: 'GET', path: '/repeated/{id}/{id}' })) + .handler(() => 'ok') const matcher = new OpenAPIMatcher({ hyphen, digit, repeated }) @@ -200,8 +208,12 @@ describe('openAPIMatcher', () => { const dotDotPing = os.handler(() => 'pong') const star = os.handler(() => 'star') const group = os.handler(() => 'group') - const batch = os.meta(openapi({ method: 'GET', path: '/items:batchGet' })).handler(() => 'batch') - const braces = os.meta(openapi({ method: 'GET', path: '/a/{id}.json' })).handler(() => 'braces') + const batch = os + .meta(openapi({ method: 'GET', path: '/items:batchGet' })) + .handler(() => 'batch') + const braces = os + .meta(openapi({ method: 'GET', path: '/a/{id}.json' })) + .handler(() => 'braces') const question = os.meta(openapi({ method: 'GET', path: '/what?' })).handler(() => 'question') const matcher = new OpenAPIMatcher({ @@ -215,24 +227,45 @@ describe('openAPIMatcher', () => { question, }) - await expect(matcher.match('POST', '/ping', undefined)).resolves.toMatchObject({ procedure: ping }) - await expect(matcher.match('POST', '/./ping', undefined)).resolves.toMatchObject({ procedure: dotPing }) - await expect(matcher.match('POST', '/../ping', undefined)).resolves.toMatchObject({ procedure: dotDotPing }) + await expect(matcher.match('POST', '/ping', undefined)).resolves.toMatchObject({ + procedure: ping, + }) + await expect(matcher.match('POST', '/./ping', undefined)).resolves.toMatchObject({ + procedure: dotPing, + }) + await expect(matcher.match('POST', '/../ping', undefined)).resolves.toMatchObject({ + procedure: dotDotPing, + }) - await expect(matcher.match('POST', '/*', undefined)).resolves.toMatchObject({ procedure: star, params: undefined }) + await expect(matcher.match('POST', '/*', undefined)).resolves.toMatchObject({ + procedure: star, + params: undefined, + }) await expect(matcher.match('POST', '/anything', undefined)).resolves.toBeUndefined() - await expect(matcher.match('POST', '/(x)', undefined)).resolves.toMatchObject({ procedure: group, params: undefined }) + await expect(matcher.match('POST', '/(x)', undefined)).resolves.toMatchObject({ + procedure: group, + params: undefined, + }) await expect(matcher.match('POST', '/x', undefined)).resolves.toBeUndefined() - await expect(matcher.match('GET', '/items:batchGet', undefined)).resolves.toMatchObject({ procedure: batch, params: undefined }) + await expect(matcher.match('GET', '/items:batchGet', undefined)).resolves.toMatchObject({ + procedure: batch, + params: undefined, + }) await expect(matcher.match('GET', '/itemsfoo', undefined)).resolves.toBeUndefined() // only whole-segment `{name}` is a param - await expect(matcher.match('GET', '/a/%7Bid%7D.json', undefined)).resolves.toMatchObject({ procedure: braces, params: undefined }) + await expect(matcher.match('GET', '/a/%7Bid%7D.json', undefined)).resolves.toMatchObject({ + procedure: braces, + params: undefined, + }) await expect(matcher.match('GET', '/a/id.json', undefined)).resolves.toBeUndefined() - await expect(matcher.match('GET', '/what%3F', undefined)).resolves.toMatchObject({ procedure: question, params: undefined }) + await expect(matcher.match('GET', '/what%3F', undefined)).resolves.toMatchObject({ + procedure: question, + params: undefined, + }) await expect(matcher.match('GET', '/what', undefined)).resolves.toBeUndefined() }) @@ -251,7 +284,9 @@ describe('openAPIMatcher', () => { }) it('matches path text whether the request leaves it raw or percent-encodes it', async () => { - const procedure = os.meta(openapi({ method: 'GET', path: '/v1^beta/items:batchGet/{id}' })).handler(() => 'ok') + const procedure = os + .meta(openapi({ method: 'GET', path: '/v1^beta/items:batchGet/{id}' })) + .handler(() => 'ok') const matcher = new OpenAPIMatcher({ procedure }) @@ -267,12 +302,16 @@ describe('openAPIMatcher', () => { }) } - await expect(matcher.match('GET', '/v1beta/items:batchGet/a^b', undefined)).resolves.toBeUndefined() + await expect( + matcher.match('GET', '/v1beta/items:batchGet/a^b', undefined), + ).resolves.toBeUndefined() }) it('prefers a static route over a dynamic sibling however the request path is percent-encoded', async () => { const beta = os.meta(openapi({ method: 'GET', path: '/v1^beta/items' })).handler(() => 'beta') - const version = os.meta(openapi({ method: 'GET', path: '/{version}/items' })).handler(() => 'version') + const version = os + .meta(openapi({ method: 'GET', path: '/{version}/items' })) + .handler(() => 'version') const me = os.meta(openapi({ method: 'GET', path: '/users/me' })).handler(() => 'me') const byId = os.meta(openapi({ method: 'GET', path: '/users/{id}' })).handler(() => 'byId') @@ -310,16 +349,24 @@ describe('openAPIMatcher', () => { it('matches path text however the route and the request encode it', async () => { const generated = os.handler(() => 'generated') - const explicit = os.meta(openapi({ method: 'GET', path: '/x%3Ay/a%2Fb/{id}' })).handler(() => 'explicit') + const explicit = os + .meta(openapi({ method: 'GET', path: '/x%3Ay/a%2Fb/{id}' })) + .handler(() => 'explicit') const matcher = new OpenAPIMatcher({ 'a:b{c}': generated, explicit }) for (const pathname of ['/a:b%7Bc%7D', '/a%3Ab%7bc%7d', '/a:b{c}'] as const) { - await expect(matcher.match('POST', pathname, undefined)).resolves.toMatchObject({ procedure: generated, params: undefined }) + await expect(matcher.match('POST', pathname, undefined)).resolves.toMatchObject({ + procedure: generated, + params: undefined, + }) } for (const pathname of ['/x:y/a%2Fb/1', '/x%3Ay/a%2fb/1'] as const) { - await expect(matcher.match('GET', pathname, undefined)).resolves.toMatchObject({ procedure: explicit, params: { id: '1' } }) + await expect(matcher.match('GET', pathname, undefined)).resolves.toMatchObject({ + procedure: explicit, + params: { id: '1' }, + }) } // an encoded slash stays part of its segment @@ -327,22 +374,33 @@ describe('openAPIMatcher', () => { }) it('matches every ASCII char in path text, raw or percent-encoded', async () => { - const chars = Array.from({ length: 0x7F - 0x20 }, (_, i) => String.fromCharCode(0x20 + i)).filter(char => char !== '/') - - const matcher = new OpenAPIMatcher(Object.fromEntries(chars.map(char => [ - char, - os.meta(openapi({ method: 'GET', path: `/x${char}y` })).handler(() => char), - ]))) + const chars = Array.from({ length: 0x7f - 0x20 }, (_, i) => + String.fromCharCode(0x20 + i), + ).filter((char) => char !== '/') + + const matcher = new OpenAPIMatcher( + Object.fromEntries( + chars.map((char) => [ + char, + os.meta(openapi({ method: 'GET', path: `/x${char}y` })).handler(() => char), + ]), + ), + ) for (const char of chars) { for (const pathname of [`/x${char}y`, `/x${encodeURIComponent(char)}y`] as const) { - await expect(matcher.match('GET', pathname, undefined)).resolves.toMatchObject({ path: [char], params: undefined }) + await expect(matcher.match('GET', pathname, undefined)).resolves.toMatchObject({ + path: [char], + params: undefined, + }) } } }) it('matches segments after a catch-all param', async () => { - const raw = os.meta(openapi({ method: 'GET', path: '/files/{+path}/raw' })).handler(() => 'ok') + const raw = os + .meta(openapi({ method: 'GET', path: '/files/{+path}/raw' })) + .handler(() => 'ok') const matcher = new OpenAPIMatcher({ raw }) @@ -389,7 +447,9 @@ describe('openAPIMatcher', () => { }) it('throws when a path has more than one catch-all param', () => { - const procedure = os.meta(openapi({ method: 'GET', path: '/{+a}/x/{+b}' })).handler(() => 'ok') + const procedure = os + .meta(openapi({ method: 'GET', path: '/{+a}/x/{+b}' })) + .handler(() => 'ok') expect(() => new OpenAPIMatcher({ procedure })).toThrowError( 'OpenAPI path "/{+a}/x/{+b}" has more than one catch-all param ({+name}), but only one is supported per path.', @@ -397,7 +457,9 @@ describe('openAPIMatcher', () => { }) it('treats `{+name}` that is not a whole segment as literal text, even more than once', async () => { - const procedure = os.meta(openapi({ method: 'GET', path: '/a{+b}/c{+d}' })).handler(() => 'ok') + const procedure = os + .meta(openapi({ method: 'GET', path: '/a{+b}/c{+d}' })) + .handler(() => 'ok') const matcher = new OpenAPIMatcher({ procedure }) @@ -447,9 +509,7 @@ describe('openAPIMatcher', () => { 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 info = os.meta(openapi({ method: 'GET', path: '/info' })).handler(() => 'info') const loader = vi.fn(async () => ({ default: { info }, @@ -475,9 +535,7 @@ describe('openAPIMatcher', () => { }) 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') + const info = os.meta(openapi({ method: 'GET', path: '/info/{tab}' })).handler(() => 'info') const loader = vi.fn(async () => ({ default: { info }, @@ -487,7 +545,9 @@ describe('openAPIMatcher', () => { user: os.meta(openapi({ prefix: '/users/{userId}' })).lazy(loader), }) - await expect(matcher.match('GET', '/projects/42/info/general', undefined)).resolves.toBeUndefined() + await expect( + matcher.match('GET', '/projects/42/info/general', undefined), + ).resolves.toBeUndefined() expect(loader).toHaveBeenCalledTimes(0) const firstResult = await matcher.match('GET', '/users/din/info/settings', undefined) @@ -501,7 +561,9 @@ describe('openAPIMatcher', () => { prefix: '/users/{userId}', }) - await expect(matcher.match('GET', '/users/din/info/settings', undefined)).resolves.toEqual(firstResult) + await expect(matcher.match('GET', '/users/din/info/settings', undefined)).resolves.toEqual( + firstResult, + ) expect(loader).toHaveBeenCalledTimes(1) }) @@ -536,23 +598,25 @@ describe('openAPIMatcher', () => { it('retries a lazy router whose indexing fails on every match', async () => { const valid = os.meta(openapi({ method: 'GET', path: '/valid' })).handler(() => 'valid') - const invalid = os.meta(openapi({ method: 'GET', path: '/{+a}/x/{+b}' })).handler(() => 'invalid') + const invalid = os + .meta(openapi({ method: 'GET', path: '/{+a}/x/{+b}' })) + .handler(() => 'invalid') const loader = vi.fn(async () => ({ default: { valid, invalid } })) const matcher = new OpenAPIMatcher({ lazy: os.lazy(loader) }) for (let i = 0; i < 3; i++) { - await expect(matcher.match('GET', '/valid', undefined)).rejects.toThrowError('more than one catch-all param') + await expect(matcher.match('GET', '/valid', undefined)).rejects.toThrowError( + 'more than one catch-all param', + ) } expect(loader).toHaveBeenCalledTimes(3) }) it('retries a lazy router whose load fails, synchronously or asynchronously', async () => { - const info = os - .meta(openapi({ method: 'GET', path: '/info' })) - .handler(() => 'info') + const info = os.meta(openapi({ method: 'GET', path: '/info' })).handler(() => 'info') let attempts = 0 const loader = vi.fn(() => { @@ -583,9 +647,7 @@ describe('openAPIMatcher', () => { }) it('resolves a prefixed lazy router that only matches after percent-decoding', async () => { - const info = os - .meta(openapi({ method: 'GET', path: '/info' })) - .handler(() => 'info') + const info = os.meta(openapi({ method: 'GET', path: '/info' })).handler(() => 'info') const loader = vi.fn(async () => ({ default: { info } })) @@ -608,31 +670,32 @@ describe('openAPIMatcher', () => { expect(loader).toHaveBeenCalledTimes(1) }) - it.each([ - '/v1^beta/info', - '/v1%5Ebeta/info', - '/v1%5ebeta/info', - ] as const)('resolves a prefixed lazy router whose prefix holds a char the request may percent-encode: %s', async (pathname) => { - const info = os.meta(openapi({ method: 'GET', path: '/info' })).handler(() => 'info') - const loader = vi.fn(async () => ({ default: { info } })) + it.each(['/v1^beta/info', '/v1%5Ebeta/info', '/v1%5ebeta/info'] as const)( + 'resolves a prefixed lazy router whose prefix holds a char the request may percent-encode: %s', + async (pathname) => { + const info = os.meta(openapi({ method: 'GET', path: '/info' })).handler(() => 'info') + const loader = vi.fn(async () => ({ default: { info } })) - const matcher = new OpenAPIMatcher({ - beta: os.meta(openapi({ prefix: '/v1^beta' })).lazy(loader), - }) + const matcher = new OpenAPIMatcher({ + beta: os.meta(openapi({ prefix: '/v1^beta' })).lazy(loader), + }) - await expect(matcher.match('GET', '/v1beta/info', undefined)).resolves.toBeUndefined() - expect(loader).toHaveBeenCalledTimes(0) + await expect(matcher.match('GET', '/v1beta/info', undefined)).resolves.toBeUndefined() + expect(loader).toHaveBeenCalledTimes(0) - await expect(matcher.match('GET', pathname, undefined)).resolves.toMatchObject({ - path: ['beta', 'info'], - params: undefined, - }) - expect(loader).toHaveBeenCalledTimes(1) - }) + await expect(matcher.match('GET', pathname, undefined)).resolves.toMatchObject({ + path: ['beta', 'info'], + params: undefined, + }) + expect(loader).toHaveBeenCalledTimes(1) + }, + ) it('resolves a prefixed lazy router before a dynamic sibling can catch a percent-encoded path', async () => { const info = os.meta(openapi({ method: 'GET', path: '/info' })).handler(() => 'info') - const version = os.meta(openapi({ method: 'GET', path: '/{version}/info' })).handler(() => 'version') + const version = os + .meta(openapi({ method: 'GET', path: '/{version}/info' })) + .handler(() => 'version') const loader = vi.fn(async () => ({ default: { info } })) @@ -649,9 +712,7 @@ describe('openAPIMatcher', () => { }) it('resolves nested lazy routers added during the same match', async () => { - const summary = os - .meta(openapi({ method: 'GET', path: '/summary' })) - .handler(() => 'summary') + const summary = os.meta(openapi({ method: 'GET', path: '/summary' })).handler(() => 'summary') const projectLoader = vi.fn(async () => ({ default: { summary }, @@ -678,7 +739,9 @@ describe('openAPIMatcher', () => { prefix: '/projects/{projectId}', }) - await expect(matcher.match('GET', '/projects/42/summary', undefined)).resolves.toEqual(firstResult) + await expect(matcher.match('GET', '/projects/42/summary', undefined)).resolves.toEqual( + firstResult, + ) expect(outerLoader).toHaveBeenCalledTimes(1) expect(projectLoader).toHaveBeenCalledTimes(1) @@ -720,13 +783,13 @@ describe('openAPIMatcher', () => { const paths = ['/leaf1', '/p2/leaf2', '/p2/p3/leaf3'] as const const results = await Promise.all( - paths.flatMap(path => [ + paths.flatMap((path) => [ matcher.match('GET', path, undefined), matcher.match('GET', path, undefined), ]), ) - expect(results.map(result => result?.path.join('/'))).toEqual([ + expect(results.map((result) => result?.path.join('/'))).toEqual([ 'lazy/leaf1', 'lazy/leaf1', 'lazy/l2/leaf2', @@ -747,9 +810,7 @@ describe('openAPIMatcher', () => { 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'), + ping: os.meta(openapi({ method: 'GET', path: '/implementation' })).handler(() => 'pong'), } const contract = { @@ -781,9 +842,11 @@ describe('openAPIMatcher', () => { missing: oc.meta(openapi({ method: 'GET', path: '/missing' })), } - const matcher = new OpenAPIMatcher(implement(contract).router({ - ping: os.handler(() => 'pong'), - } as any)) + const matcher = new OpenAPIMatcher( + implement(contract).router({ + ping: os.handler(() => 'pong'), + } as any), + ) await expect(matcher.match('GET', '/missing', undefined)).rejects.toThrowError( '[Contract-First] Missing or invalid implementation for procedure at path: "missing"', @@ -799,7 +862,9 @@ describe('openAPIMatcher', () => { .handler(() => 'original'), } const implementer = implement(original, { disableInputValidation: false }) - const matcher = new OpenAPIMatcher(implementer.router({ ping: implementer.ping.handler(() => 'implemented') })) + const matcher = new OpenAPIMatcher( + implementer.router({ ping: implementer.ping.handler(() => 'implemented') }), + ) const { procedure } = (await matcher.match('GET', '/ping', undefined))! diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.ts b/packages/openapi/src/adapters/standard/openapi-matcher.ts index c3b3f8e557..aa1da7b080 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.ts @@ -1,11 +1,17 @@ +import { createPrefixMatcher, Router } from '@openapi-spec/router' import type { AnyProcedureContract } from '@orpc/contract' import type { AnyProcedure, AnyRouter, WalkProcedureContractsLazyResult } from '@orpc/server' +import { + Procedure, + resolveContractProcedure, + unlazy, + walkProcedureContractsSync, +} from '@orpc/server' import type { Value } from '@orpc/shared' -import { createPrefixMatcher, Router } from '@openapi-spec/router' -import { Procedure, resolveContractProcedure, unlazy, walkProcedureContractsSync } from '@orpc/server' import { mergeHttpPath, pathToHttpPath, value } from '@orpc/shared' -import { DEFAULT_OPENAPI_METHOD } from '../../constants' -import { getOpenAPIMeta } from '../../meta' + +import { DEFAULT_OPENAPI_METHOD } from '../../constants.ts' +import { getOpenAPIMeta } from '../../meta.ts' export interface OpenAPIMatcherOptions { /** @@ -13,7 +19,9 @@ export interface OpenAPIMatcherOptions { * * @default true */ - filter?: Value | undefined + filter?: + | Value + | undefined } interface TreeEntry { @@ -42,22 +50,26 @@ export class OpenAPIMatcher { } private index(router: AnyRouter, path: string[] = []): void { - const lazyResults = walkProcedureContractsSync(router, (contract, path) => { - if (!value(this.filter, contract, path)) { - return - } - - const meta = getOpenAPIMeta(contract) - const method = meta?.method ?? DEFAULT_OPENAPI_METHOD - const postHttpPath = meta?.path ?? pathToHttpPath(path) - const httpPath = meta?.prefix ? mergeHttpPath(meta.prefix, postHttpPath) : postHttpPath - - this.tree.add(method, httpPath, { - path, - contract, - procedure: contract instanceof Procedure ? contract : undefined, - }) - }, path) + const lazyResults = walkProcedureContractsSync( + router, + (contract, path) => { + if (!value(this.filter, contract, path)) { + return + } + + const meta = getOpenAPIMeta(contract) + const method = meta?.method ?? DEFAULT_OPENAPI_METHOD + const postHttpPath = meta?.path ?? pathToHttpPath(path) + const httpPath = meta?.prefix ? mergeHttpPath(meta.prefix, postHttpPath) : postHttpPath + + this.tree.add(method, httpPath, { + path, + contract, + procedure: contract instanceof Procedure ? contract : undefined, + }) + }, + path, + ) for (const result of lazyResults) { const prefix = getOpenAPIMeta(result.router)?.prefix @@ -73,7 +85,10 @@ export class OpenAPIMatcher { method: string, pathname: `/${string}`, prefix: `/${string}` | undefined, - ): Promise<{ path: string[], procedure: AnyProcedure, params?: Record | undefined } | undefined> { + ): Promise< + | { path: string[]; procedure: AnyProcedure; params?: Record | undefined } + | undefined + > { if (prefix) { if (!pathname.startsWith(prefix)) { return undefined @@ -83,14 +98,11 @@ export class OpenAPIMatcher { if (charAfterPrefix === '/') { pathname = pathname.slice(prefix.length) as `/${string}` - } - else if (charAfterPrefix === undefined) { + } else if (charAfterPrefix === undefined) { pathname = '/' - } - else if (prefix[prefix.length - 1] === '/') { + } else if (prefix[prefix.length - 1] === '/') { pathname = pathname.slice(prefix.length - 1) as `/${string}` - } - else { + } else { return undefined } } @@ -111,7 +123,7 @@ export class OpenAPIMatcher { return { path: entry.path, - procedure: entry.procedure ?? await this.resolveProcedure(entry), + procedure: entry.procedure ?? (await this.resolveProcedure(entry)), params: match.params, } } diff --git a/packages/openapi/src/adapters/standard/utils.test.ts b/packages/openapi/src/adapters/standard/utils.test.ts index 872f8b07e5..1867bb0445 100644 --- a/packages/openapi/src/adapters/standard/utils.test.ts +++ b/packages/openapi/src/adapters/standard/utils.test.ts @@ -1,26 +1,36 @@ -import { OpenAPISerializer } from '../../openapi-serializer' -import { serializeHeaders } from './utils' +import { OpenAPISerializer } from '../../openapi-serializer.ts' +import { serializeHeaders } from './utils.ts' const serializer = new OpenAPISerializer() describe('serializeHeaders', () => { it('keeps string and string[] values as-is', () => { - expect(serializeHeaders({ - 'x-string': 'value', - 'x-array': ['a', 'b'], - }, serializer)).toEqual({ + expect( + serializeHeaders( + { + 'x-string': 'value', + 'x-array': ['a', 'b'], + }, + serializer, + ), + ).toEqual({ 'x-string': 'value', 'x-array': ['a', 'b'], }) }) it('serializes non-string values into strings', () => { - expect(serializeHeaders({ - 'x-number': 42, - 'x-boolean': true, - 'x-date': new Date('2020-01-02T03:04:05.000Z'), - 'x-array': ['a', 1, false, new Date('2020-01-02T03:04:05.000Z')], - }, serializer)).toEqual({ + expect( + serializeHeaders( + { + 'x-number': 42, + 'x-boolean': true, + 'x-date': new Date('2020-01-02T03:04:05.000Z'), + 'x-array': ['a', 1, false, new Date('2020-01-02T03:04:05.000Z')], + }, + serializer, + ), + ).toEqual({ 'x-number': '42', 'x-boolean': 'true', 'x-date': '2020-01-02T03:04:05.000Z', @@ -29,11 +39,16 @@ describe('serializeHeaders', () => { }) it('serializes objects as comma-delimited key,value pairs', () => { - expect(serializeHeaders({ - 'x-object': { enabled: true, count: 2 }, - 'x-object-nested-date': { at: new Date('2020-01-02T03:04:05.000Z') }, - 'x-object-skip-nullish': { keep: 'yes', skip: null, omit: undefined }, - }, serializer)).toEqual({ + expect( + serializeHeaders( + { + 'x-object': { enabled: true, count: 2 }, + 'x-object-nested-date': { at: new Date('2020-01-02T03:04:05.000Z') }, + 'x-object-skip-nullish': { keep: 'yes', skip: null, omit: undefined }, + }, + serializer, + ), + ).toEqual({ 'x-object': 'enabled,true,count,2', 'x-object-nested-date': 'at,2020-01-02T03:04:05.000Z', 'x-object-skip-nullish': 'keep,yes', @@ -41,29 +56,45 @@ describe('serializeHeaders', () => { }) it('merges values that serialize into arrays as a single comma-delimited line', () => { - expect(serializeHeaders({ - 'x-set': new Set(['a', 'b']), - 'x-map': new Map([['k1', 'v1'], ['k2', 'v2']]), - }, serializer)).toEqual({ + expect( + serializeHeaders( + { + 'x-set': new Set(['a', 'b']), + 'x-map': new Map([ + ['k1', 'v1'], + ['k2', 'v2'], + ]), + }, + serializer, + ), + ).toEqual({ 'x-set': 'a,b', 'x-map': 'k1,v1,k2,v2', }) }) it('treats only actual arrays as multi-value headers, array items stay a single line each', () => { - expect(serializeHeaders({ - 'x-array': ['a', new Set(['b', 'c']), { k: 'v' }], - }, serializer)).toEqual({ + expect( + serializeHeaders( + { + 'x-array': ['a', new Set(['b', 'c']), { k: 'v' }], + }, + serializer, + ), + ).toEqual({ 'x-array': ['a', 'b,c', 'k,v'], }) }) it('drops undefined and null values, including array items', () => { - const serialized = serializeHeaders({ - 'x-null': null, - 'x-undefined': undefined, - 'x-array': ['keep', null, undefined], - }, serializer) + const serialized = serializeHeaders( + { + 'x-null': null, + 'x-undefined': undefined, + 'x-array': ['keep', null, undefined], + }, + serializer, + ) expect(serialized).toEqual({ 'x-array': ['keep'] }) expect(Object.keys(serialized)).toEqual(['x-array']) @@ -72,19 +103,25 @@ describe('serializeHeaders', () => { it('ignores body-level serialize defaults like asFormData', () => { const formDataSerializer = new OpenAPISerializer({ serialize: { asFormData: true } }) - expect(serializeHeaders({ - 'x-string': 'value', - 'x-object': { enabled: true }, - }, formDataSerializer)).toEqual({ + expect( + serializeHeaders( + { + 'x-string': 'value', + 'x-object': { enabled: true }, + }, + formDataSerializer, + ), + ).toEqual({ 'x-string': 'value', 'x-object': 'enabled,true', }) }) it('prevents prototype injection via header keys', () => { - const serialized = serializeHeaders(JSON.parse( - '{"__proto__": { "polluted": "yes" }, "constructor": "c", "toString": "t"}', - ), serializer) + const serialized = serializeHeaders( + JSON.parse('{"__proto__": { "polluted": "yes" }, "constructor": "c", "toString": "t"}'), + serializer, + ) expect(({} as any).polluted).toBeUndefined() diff --git a/packages/openapi/src/adapters/standard/utils.ts b/packages/openapi/src/adapters/standard/utils.ts index 4ef7f3f4c3..25b5c57b8e 100644 --- a/packages/openapi/src/adapters/standard/utils.ts +++ b/packages/openapi/src/adapters/standard/utils.ts @@ -1,7 +1,8 @@ import type { Public } from '@orpc/shared' -import type { StandardHeaders } from '@standard-server/core' -import type { OpenAPIJsonSerializer } from '../../openapi-json-serializer' import { isTypescriptObject, NullProtoObj } from '@orpc/shared' +import type { StandardHeaders } from '@standard-server/core' + +import type { OpenAPIJsonSerializer } from '../../openapi-json-serializer.ts' export function serializeHeaders( headers: object, @@ -48,7 +49,7 @@ function serializeHeaderValue( if (Array.isArray(serialized)) { return serialized - .filter(item => item !== undefined && item !== null) + .filter((item) => item !== undefined && item !== null) .map(String) .join(',') } @@ -56,11 +57,12 @@ function serializeHeaderValue( if (isTypescriptObject(serialized)) { return Object.entries(serialized) .filter(([, val]) => val !== undefined && val !== null) - .map(([key, val]) => `${String(key)},${String(val)}`) + .map(([key, val]) => `${key},${String(val)}`) .join(',') } if (serialized !== undefined && serialized !== null) { + // oxlint-disable-next-line typescript/no-base-to-string return String(serialized) } diff --git a/packages/openapi/src/bracket-notation.test.ts b/packages/openapi/src/bracket-notation.test.ts index 41f58ea41c..c215db32a9 100644 --- a/packages/openapi/src/bracket-notation.test.ts +++ b/packages/openapi/src/bracket-notation.test.ts @@ -1,5 +1,6 @@ import { setFlagsFromString } from 'node:v8' -import { BracketNotationSerializer } from './bracket-notation' + +import { BracketNotationSerializer } from './bracket-notation.ts' describe('bracket notation serializer', () => { const serializer = new BracketNotationSerializer() @@ -26,19 +27,16 @@ describe('bracket notation serializer', () => { 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) - }) + 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], - ]) + expect(serializer.serialize(1)).toEqual([['', 1]]) }) it('can serialize objects', () => { @@ -104,126 +102,164 @@ describe('bracket notation serializer', () => { }) 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] }) + 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] }) + 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 }) + 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 }) + 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 }) + 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 }) + 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 } }) + 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('converts an explicitly indexed array to an object when a [] key follows', () => { - expect(serializer.deserialize([ - ['a[0]', 1], - ['a[]', 2], - ])).toEqual({ a: { '0': 1, '': 2 } }) + expect( + serializer.deserialize([ + ['a[0]', 1], + ['a[]', 2], + ]), + ).toEqual({ a: { '0': 1, '': 2 } }) }) 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 }) + 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({ + 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, @@ -233,66 +269,82 @@ describe('bracket notation serializer', () => { }) it('keeps a leaf value as the first item when bracket keys follow it, like a repeated key', () => { - expect(serializer.deserialize([ - ['a', '1'], - ['a[1]', '2'], - ])).toEqual({ a: ['1', '2'] }) - - expect(serializer.deserialize([ - ['a', '1'], - ['a[0]', '2'], - ])).toEqual({ a: [['1', '2']] }) - - expect(serializer.deserialize([ - ['a', '1'], - ['a[]', '2'], - ])).toEqual({ a: { '0': '1', '': '2' } }) - - expect(serializer.deserialize([ - ['a', '1'], - ['a[1][b]', '2'], - ])).toEqual({ a: ['1', { b: '2' }] }) - - expect(serializer.deserialize([ - ['a', undefined], - ['a[1]', '2'], - ])).toEqual({ a: [undefined, '2'] }) + expect( + serializer.deserialize([ + ['a', '1'], + ['a[1]', '2'], + ]), + ).toEqual({ a: ['1', '2'] }) + + expect( + serializer.deserialize([ + ['a', '1'], + ['a[0]', '2'], + ]), + ).toEqual({ a: [['1', '2']] }) + + expect( + serializer.deserialize([ + ['a', '1'], + ['a[]', '2'], + ]), + ).toEqual({ a: { '0': '1', '': '2' } }) + + expect( + serializer.deserialize([ + ['a', '1'], + ['a[1][b]', '2'], + ]), + ).toEqual({ a: ['1', { b: '2' }] }) + + expect( + serializer.deserialize([ + ['a', undefined], + ['a[1]', '2'], + ]), + ).toEqual({ a: [undefined, '2'] }) }) it('should be an array if [] conflict keys', () => { - expect(serializer.deserialize([ - ['users[]', 1], - ['users[]', 2], - ['users[name]', 3], - ])).toEqual({ + 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({ + 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({ + 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': { @@ -304,18 +356,22 @@ describe('bracket notation serializer', () => { }) 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 }] } }) + 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('deserializes a realistic search form', () => { - const entries = Array.from(new URLSearchParams( - 'q=shoes&filters[size][]=41&filters[size][]=42&filters[color]=red&page=2', - ).entries()) + const entries = Array.from( + new URLSearchParams( + 'q=shoes&filters[size][]=41&filters[size][]=42&filters[color]=red&page=2', + ).entries(), + ) expect(serializer.deserialize(entries)).toEqual({ q: 'shoes', @@ -340,28 +396,41 @@ describe('bracket notation serializer', () => { const limited = new BracketNotationSerializer({ maxDeserializingEmptySlots: 4 }) it('turns an array into an object when an index leaves more empty slots than the limit (default 1,000)', () => { - expect(serializer.deserialize([ - ['arr[1000]', 3], - ])).toEqual({ arr: (() => { - const arr = [] - arr[1000] = 3 - return arr - })() }) + expect(serializer.deserialize([['arr[1000]', 3]])).toEqual({ + arr: (() => { + const arr = [] + arr[1000] = 3 + return arr + })(), + }) expect(serializer.deserialize([['arr[1001]', 3]])).toEqual({ arr: { 1001: 3 } }) - expect(serializer.deserialize([['arr[1]', 1], ['arr[5000]', 2]])).toEqual({ arr: { 1: 1, 5000: 2 } }) - expect(serializer.deserialize([['arr[4294967294]', 'x']])).toEqual({ arr: { 4294967294: 'x' } }) + expect( + serializer.deserialize([ + ['arr[1]', 1], + ['arr[5000]', 2], + ]), + ).toEqual({ arr: { 1: 1, 5000: 2 } }) + expect(serializer.deserialize([['arr[4294967294]', 'x']])).toEqual({ + arr: { 4294967294: 'x' }, + }) // sequential indexes leave no empty slots, so long arrays stay arrays - expect(serializer.deserialize(Array.from({ length: 2000 }, (_, i): [string, unknown] => [`arr[${i}]`, i]))).toEqual({ + expect( + serializer.deserialize( + Array.from({ length: 2000 }, (_, i): [string, unknown] => [`arr[${i}]`, i]), + ), + ).toEqual({ arr: Array.from({ length: 2000 }, (_, i) => i), }) // repeated keys append without using the limit - expect(serializer.deserialize([ - ['arr[999]', 3], - ['arr', 4], - ])).toEqual({ + expect( + serializer.deserialize([ + ['arr[999]', 3], + ['arr', 4], + ]), + ).toEqual({ arr: (() => { const arr = [] arr[999] = 3 @@ -372,18 +441,38 @@ describe('bracket notation serializer', () => { }) it('counts the empty slots that explicit array indexes leave across the payload', () => { - expect(limited.deserialize([['a[0]', 1], ['a[3]', 2], ['b[2]', 3]])).toEqual({ + expect( + limited.deserialize([ + ['a[0]', 1], + ['a[3]', 2], + ['b[2]', 3], + ]), + ).toEqual({ a: [1, undefined, undefined, 2], b: [undefined, undefined, 3], }) - expect(limited.deserialize([['a[0]', 1], ['a[3]', 2], ['b[3]', 3]])).toEqual({ + expect( + limited.deserialize([ + ['a[0]', 1], + ['a[3]', 2], + ['b[3]', 3], + ]), + ).toEqual({ a: [1, undefined, undefined, 2], b: { 3: 3 }, }) // writing below an array's length leaves no empty slots - expect(limited.deserialize([['a[3]', 1], ['a[0]', 2], ['a[1]', 3], ['a[2]', 4], ['b[1]', 5]])).toEqual({ + expect( + limited.deserialize([ + ['a[3]', 1], + ['a[0]', 2], + ['a[1]', 3], + ['a[2]', 4], + ['b[1]', 5], + ]), + ).toEqual({ a: [2, 3, 4, 1], b: [undefined, 5], }) @@ -392,26 +481,45 @@ describe('bracket notation serializer', () => { it('stores integer keys that leave gaps in objects sparsely, without a trace', () => { setFlagsFromString('--allow-natives-syntax') - // eslint-disable-next-line no-new-func - const hasSparseIntegerKeys = new Function('o', 'return %HasDictionaryElements(o)') as (o: object) => boolean + // oxlint-disable-next-line typescript/no-implied-eval + const hasSparseIntegerKeys = new Function('o', 'return %HasDictionaryElements(o)') as ( + o: object, + ) => boolean - const result = serializer.deserialize([['a[x]', 1], ['a[999]', 2], ['b[x]', 3], ['b[0]', 4], ['b[1]', 5], ['999', 6]]) as any + const result = serializer.deserialize([ + ['a[x]', 1], + ['a[999]', 2], + ['b[x]', 3], + ['b[0]', 4], + ['b[1]', 5], + ['999', 6], + ]) as any expect(hasSparseIntegerKeys(result.a)).toBe(true) expect(hasSparseIntegerKeys(result.b)).toBe(false) expect(hasSparseIntegerKeys(result)).toBe(true) expect(Object.keys(result.a)).toEqual(['999', 'x']) - expect(serializer.deserialize([['a[x]', 1], ['a[4294967294]', 2], ['a[5]', 3]])).toEqual({ + expect( + serializer.deserialize([ + ['a[x]', 1], + ['a[4294967294]', 2], + ['a[5]', 3], + ]), + ).toEqual({ a: { x: 1, 4294967294: 2, 5: 3 }, }) // objects passed in by the caller are left as they are - expect(serializer.deserialize([['a', Object.seal({ 5: 1 })], ['a[5]', 2]])).toEqual({ a: { 5: [1, 2] } }) + expect( + serializer.deserialize([ + ['a', Object.seal({ 5: 1 })], + ['a[5]', 2], + ]), + ).toEqual({ a: { 5: [1, 2] } }) }) it('can prevent prototype pollution attack', () => { - /* eslint-disable no-proto, no-restricted-properties */ const result = serializer.deserialize([ ['__proto__[polluted]', '1'], ['constructor[polluted]', '2'], @@ -440,7 +548,6 @@ describe('bracket notation serializer', () => { 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('can prevent constructor.prototype pollution attack', () => { @@ -459,7 +566,6 @@ describe('bracket notation serializer', () => { }) it('can prevent prototype pollution attack through object values', () => { - /* eslint-disable no-proto, no-restricted-properties */ const result = serializer.deserialize([ ['a', {}], ['a[__proto__][polluted]', '1'], @@ -476,13 +582,18 @@ describe('bracket notation serializer', () => { expect(result.b.c).toBe('2') expect(result.b.constructor.prototype.polluted).toBe('3') expect(result.b.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]])] }], + [{}], + [ + { + 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/src/bracket-notation.ts b/packages/openapi/src/bracket-notation.ts index 5d27677b71..f9da8e2ed0 100644 --- a/packages/openapi/src/bracket-notation.ts +++ b/packages/openapi/src/bracket-notation.ts @@ -32,20 +32,21 @@ export class BracketNotationSerializer { return result } - private internalSerialize(data: unknown, path: string, isRoot: boolean, result: BracketNotationSerializeResult): void { + private internalSerialize( + data: unknown, + path: string, + isRoot: boolean, + result: BracketNotationSerializeResult, + ): void { if (Array.isArray(data)) { data.forEach((item, i) => { this.internalSerialize(item, isRoot ? i.toString() : `${path}[${i}]`, false, result) }) - } - - else if (isPlainObject(data)) { + } else if (isPlainObject(data)) { for (const key of Object.keys(data)) { this.internalSerialize(data[key], isRoot ? key : `${path}[${key}]`, false, result) } - } - - else { + } else { result.push([path, data]) } } @@ -86,20 +87,21 @@ export class BracketNotationSerializer { const index = internalToArrayIndex(segment) const emptySlots = index === undefined ? 0 : Math.max(0, index - length) - const canBeArray = segment === '' - ? isLast && (isPushStyle || length === 0) - : index !== undefined && !(isLast && isPushStyle) && emptySlots <= emptySlotsLeft + const canBeArray = + segment === '' + ? isLast && (isPushStyle || length === 0) + : index !== undefined && !(isLast && isPushStyle) && emptySlots <= emptySlotsLeft if (canBeArray) { emptySlotsLeft -= emptySlots child = isArray ? child : [] key = index ?? segment - } - else if (isArray) { + } else if (isArray) { arrayPushStyles?.delete(child) - child = isPushStyle ? internalPushStyleArrayToObject(child) : internalArrayToObject(child) - } - else { + child = isPushStyle + ? internalPushStyleArrayToObject(child) + : internalArrayToObject(child) + } else { child = new NullProtoObj() } } @@ -116,18 +118,15 @@ export class BracketNotationSerializer { arrayPushStyles ??= new WeakSet() arrayPushStyles.add(currentRef) currentRef.push(value) - } - else if (Object.hasOwn(currentRef, nextSegment)) { + } else if (Object.hasOwn(currentRef, nextSegment)) { const current = currentRef[nextSegment] if (Array.isArray(current)) { current.push(value) - } - else { + } else { internalSetOwn(currentRef, nextSegment, [current, value]) } - } - else { + } else { internalSetOwn(currentRef, nextSegment, value) } } @@ -230,12 +229,21 @@ function internalPushStyleArrayToObject(array: readonly unknown[]): Record, key: string | number, value: unknown): void { +function internalSetOwn( + container: Record, + key: string | number, + value: unknown, +): void { if (typeof key === 'string' && container instanceof NullProtoObj) { const index = internalToArrayIndex(key) // `0`, or the key right after an existing one, keeps the flat store filled - if (index !== undefined && index !== 0 && !Object.hasOwn(container, index - 1) && !Object.hasOwn(container, MAX_ARRAY_INDEX)) { + if ( + index !== undefined && + index !== 0 && + !Object.hasOwn(container, index - 1) && + !Object.hasOwn(container, MAX_ARRAY_INDEX) + ) { container[MAX_ARRAY_INDEX] = undefined delete container[MAX_ARRAY_INDEX] } diff --git a/packages/openapi/src/client-factory.test-d.ts b/packages/openapi/src/client-factory.test-d.ts index 0050f4b5af..5c359c647d 100644 --- a/packages/openapi/src/client-factory.test-d.ts +++ b/packages/openapi/src/client-factory.test-d.ts @@ -1,7 +1,8 @@ import type { ClientLink, ORPCError } from '@orpc/client' -import type { PromiseWithError } from '@orpc/shared' import { oc, type } from '@orpc/contract' -import { createContractJsonifiedClientFactory } from './client-factory' +import type { PromiseWithError } from '@orpc/shared' + +import { createContractJsonifiedClientFactory } from './client-factory.ts' const contract = { ping: oc, @@ -26,9 +27,7 @@ describe('createContractJsonifiedClientFactory', () => { const result = next() - expectTypeOf(result).toEqualTypeOf< - PromiseWithError - >() + expectTypeOf(result).toEqualTypeOf>() return result }, @@ -75,17 +74,11 @@ describe('createContractJsonifiedClientFactory', () => { it('returns a client & infers procedure return types', () => { const factory = createContractJsonifiedClientFactory(link) - expectTypeOf( - factory(contract.ping)(), - ).toEqualTypeOf< - PromiseWithError - >() + expectTypeOf(factory(contract.ping)()).toEqualTypeOf>() expectTypeOf( factory(contract.nested.pong)('string', { context: { cache: true } }), - ).toEqualTypeOf< - PromiseWithError> - >() + ).toEqualTypeOf>>() }) it('returns a router client when a router contract is passed', () => { @@ -93,15 +86,9 @@ describe('createContractJsonifiedClientFactory', () => { const client = factory(contract) - expectTypeOf( - client.ping(), - ).toEqualTypeOf< - PromiseWithError - >() + expectTypeOf(client.ping()).toEqualTypeOf>() - expectTypeOf( - client.nested.pong('string', { context: { cache: true } }), - ).toEqualTypeOf< + expectTypeOf(client.nested.pong('string', { context: { cache: true } })).toEqualTypeOf< PromiseWithError> >() }) @@ -110,13 +97,13 @@ describe('createContractJsonifiedClientFactory', () => { const factory = createContractJsonifiedClientFactory(link) // @ts-expect-error - invalid input - factory(contract.nested.pong)(123) + void factory(contract.nested.pong)(123) }) it('rejects invalid context', () => { const factory = createContractJsonifiedClientFactory(link) // @ts-expect-error - invalid context - factory(contract.nested.pong)('string', { context: { cache: 'invalid' } }) + void factory(contract.nested.pong)('string', { context: { cache: 'invalid' } }) }) }) diff --git a/packages/openapi/src/client-factory.test.ts b/packages/openapi/src/client-factory.test.ts index 13a8d8e7b3..723d22c6bd 100644 --- a/packages/openapi/src/client-factory.test.ts +++ b/packages/openapi/src/client-factory.test.ts @@ -1,6 +1,7 @@ import type { ClientContext, ClientLink } from '@orpc/client' import { createContractClientFactory } from '@orpc/contract' -import { createContractJsonifiedClientFactory } from './client-factory' + +import { createContractJsonifiedClientFactory } from './client-factory.ts' vi.mock('@orpc/contract', () => ({ createContractClientFactory: vi.fn(), diff --git a/packages/openapi/src/client-factory.ts b/packages/openapi/src/client-factory.ts index a0303d6592..363ed8d411 100644 --- a/packages/openapi/src/client-factory.ts +++ b/packages/openapi/src/client-factory.ts @@ -1,17 +1,19 @@ import type { ClientContext, ClientLink, ORPCClientOptions } from '@orpc/client' import type { RouterContract, RouterContractClient } from '@orpc/contract' -import type { JsonifiedClient } from './types' import { createContractClientFactory } from '@orpc/contract' -export interface ContractJsonifiedClientFactory< - TClientContext extends ClientContext, -> { +import type { JsonifiedClient } from './types.ts' + +export interface ContractJsonifiedClientFactory { (contract: T): JsonifiedClient> } export interface ContractJsonifiedClientFactoryOptions< TClientContext extends ClientContext, -> extends Pick>>, 'interceptors' | 'scoped'> { +> extends Pick< + ORPCClientOptions>>, + 'interceptors' | 'scoped' +> { /** * An optional reference to the root router-contract. * When provided, the client factory will automatically register the passed contract @@ -29,11 +31,12 @@ export interface ContractJsonifiedClientFactoryOptions< * * @see {@link https://orpc.dev/docs/contract/client-factory | Contract Client Factory} */ -export function createContractJsonifiedClientFactory< - TClientContext extends ClientContext, ->( +export function createContractJsonifiedClientFactory( link: ClientLink, options: ContractJsonifiedClientFactoryOptions = {}, ): ContractJsonifiedClientFactory { - return createContractClientFactory(link, options) as ContractJsonifiedClientFactory + return createContractClientFactory( + link, + options, + ) as ContractJsonifiedClientFactory } diff --git a/packages/openapi/src/constants.test.ts b/packages/openapi/src/constants.test.ts index 9b6cd7f250..a3d858a7ca 100644 --- a/packages/openapi/src/constants.test.ts +++ b/packages/openapi/src/constants.test.ts @@ -1,5 +1,5 @@ it('exports', async () => { - await expect(import('./constants')).resolves.toMatchObject({ + await expect(import('./constants.ts')).resolves.toMatchObject({ DEFAULT_OPENAPI_METHOD: expect.any(String), DEFAULT_OPENAPI_SUCCESS_DESCRIPTION: expect.any(String), DEFAULT_OPENAPI_INPUT_STRUCTURE: expect.any(String), diff --git a/packages/openapi/src/extensions/route.test.ts b/packages/openapi/src/extensions/route.test.ts index 3099a28678..46c3fee01c 100644 --- a/packages/openapi/src/extensions/route.test.ts +++ b/packages/openapi/src/extensions/route.test.ts @@ -1,8 +1,9 @@ import { oc, ProcedureContract } from '@orpc/contract' import { DecoratedProcedure, os } from '@orpc/server' -import z from 'zod' -import { getOpenAPIMeta } from '../meta' -import './route' +import * as z from 'zod' + +import { getOpenAPIMeta } from '../meta.ts' +import './route.ts' it('adds .route metadata through contract builder variants', () => { const procedure = oc diff --git a/packages/openapi/src/extensions/route.ts b/packages/openapi/src/extensions/route.ts index 4715e24c51..9919e0a89d 100644 --- a/packages/openapi/src/extensions/route.ts +++ b/packages/openapi/src/extensions/route.ts @@ -1,14 +1,13 @@ import type { AnySchema, ErrorMap } from '@orpc/contract' -import type { Context } from '@orpc/server' -import type { OpenAPIMeta } from '../meta' import { ContractBuilder } from '@orpc/contract' +import type { Context } from '@orpc/server' import { Builder, DecoratedProcedure } from '@orpc/server' -import { openapi } from '../meta' + +import type { OpenAPIMeta } from '../meta.ts' +import { openapi } from '../meta.ts' declare module '@orpc/contract' { - interface ContractBuilder< - TErrorMap extends ErrorMap, - > { + interface ContractBuilder { route(meta: OpenAPIMeta): ContractBuilder } @@ -31,7 +30,9 @@ declare module '@orpc/contract' { TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, > { - route(meta: OpenAPIMeta): ProcedureContractBuilderWithInputOutput + route( + meta: OpenAPIMeta, + ): ProcedureContractBuilderWithInputOutput } } @@ -40,10 +41,7 @@ ContractBuilder.prototype.route = function route(meta) { } declare module '@orpc/server' { - interface Builder< - TInitialContext extends Context, - TErrorMap extends ErrorMap, - > { + interface Builder { route(meta: OpenAPIMeta): Builder } @@ -61,7 +59,9 @@ declare module '@orpc/server' { TInputSchema extends AnySchema, TErrorMap extends ErrorMap, > { - route(meta: OpenAPIMeta): BuilderWithInput + route( + meta: OpenAPIMeta, + ): BuilderWithInput } interface BuilderWithOutput< @@ -70,7 +70,9 @@ declare module '@orpc/server' { TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, > { - route(meta: OpenAPIMeta): BuilderWithOutput + route( + meta: OpenAPIMeta, + ): BuilderWithOutput } interface BuilderWithInputOutput< @@ -80,7 +82,15 @@ declare module '@orpc/server' { TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, > { - route(meta: OpenAPIMeta): BuilderWithInputOutput + route( + meta: OpenAPIMeta, + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + TInputSchema, + TOutputSchema, + TErrorMap + > } interface DecoratedProcedure< @@ -90,7 +100,9 @@ declare module '@orpc/server' { TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, > { - route(meta: OpenAPIMeta): DecoratedProcedure + route( + meta: OpenAPIMeta, + ): DecoratedProcedure } } diff --git a/packages/openapi/src/helpers/form-data.test.ts b/packages/openapi/src/helpers/form-data.test.ts index 5406432e87..59498e45fd 100644 --- a/packages/openapi/src/helpers/form-data.test.ts +++ b/packages/openapi/src/helpers/form-data.test.ts @@ -1,4 +1,4 @@ -import { getIssueMessage, parseFormData } from './form-data' +import { getIssueMessage, parseFormData } from './form-data.ts' it('parseFormData', () => { expect(parseFormData(new FormData())).toEqual({}) @@ -34,22 +34,57 @@ it('getIssueMessage', () => { 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: ['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: [{ 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: ['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() + 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() - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['address2'] }] } }, '')).toBeUndefined() - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['user', 'phone1'] }] } }, '')).toBeUndefined() + expect( + getIssueMessage({ data: { issues: [{ message: 'hi', path: ['address2'] }] } }, ''), + ).toBeUndefined() + expect( + getIssueMessage({ data: { issues: [{ message: 'hi', path: ['user', 'phone1'] }] } }, ''), + ).toBeUndefined() expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['10'] }] } }, '')).toBe('hi') - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['01'] }] } }, '')).toBeUndefined() + expect( + getIssueMessage({ data: { issues: [{ message: 'hi', path: ['01'] }] } }, ''), + ).toBeUndefined() }) diff --git a/packages/openapi/src/helpers/form-data.ts b/packages/openapi/src/helpers/form-data.ts index 52259cbed9..22f1874a21 100644 --- a/packages/openapi/src/helpers/form-data.ts +++ b/packages/openapi/src/helpers/form-data.ts @@ -1,6 +1,7 @@ import { isSchemaIssue } from '@orpc/contract' import { isTypescriptObject } from '@orpc/shared' -import { BracketNotationSerializer } from '../bracket-notation' + +import { BracketNotationSerializer } from '../bracket-notation.ts' /** * Parse a form data with [bracket notation](https://orpc.dev/docs/openapi/bracket-notation) syntax @@ -63,7 +64,11 @@ export function parseFormData(form: FormData): any { * @see {@link https://orpc.dev/docs/helpers/form-data#getissuemessage | Form Data Helpers - getIssueMessage} */ export function getIssueMessage(error: unknown, path: string): string | undefined { - if (!isTypescriptObject(error) || !isTypescriptObject(error.data) || !Array.isArray(error.data.issues)) { + if ( + !isTypescriptObject(error) || + !isTypescriptObject(error.data) || + !Array.isArray(error.data.issues) + ) { return undefined } @@ -83,7 +88,9 @@ export function getIssueMessage(error: unknown, path: string): string | undefine } const issuePath = serializer.stringifyPath( - issue.path.map(segment => typeof segment === 'object' ? segment.key.toString() : segment.toString()), + issue.path.map((segment) => + typeof segment === 'object' ? segment.key.toString() : segment.toString(), + ), ) if (issuePath === path) { @@ -98,4 +105,6 @@ export function getIssueMessage(error: unknown, path: string): string | undefine return issue.message } } + + return undefined } diff --git a/packages/openapi/src/helpers/index.test.ts b/packages/openapi/src/helpers/index.test.ts index d39e54de24..354690bd48 100644 --- a/packages/openapi/src/helpers/index.test.ts +++ b/packages/openapi/src/helpers/index.test.ts @@ -1,5 +1,5 @@ it('exports helpers', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ parseFormData: expect.any(Function), }) }) diff --git a/packages/openapi/src/helpers/index.ts b/packages/openapi/src/helpers/index.ts index 2a918a3196..f70898ea57 100644 --- a/packages/openapi/src/helpers/index.ts +++ b/packages/openapi/src/helpers/index.ts @@ -1 +1 @@ -export * from './form-data' +export * from './form-data.ts' diff --git a/packages/openapi/src/index.test.ts b/packages/openapi/src/index.test.ts index c4c7f8558e..143ab62220 100644 --- a/packages/openapi/src/index.test.ts +++ b/packages/openapi/src/index.test.ts @@ -1,5 +1,5 @@ it('exports openapi meta, OpenAPIGenerator, OpenAPISerializer, OpenAPIJsonSerializer, BracketNotationSerializer', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ openapi: expect.any(Function), OpenAPIGenerator: expect.any(Function), OpenAPISerializer: expect.any(Function), diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 261fb78de6..af19f27730 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -1,22 +1,16 @@ -export * from './bracket-notation' -export * from './client-factory' -export { DEFAULT_OPENAPI_METHOD } from './constants' -export * from './meta' -export * from './openapi-generator' -export * from './openapi-json-serializer' -export * from './openapi-serializer' -export * from './router-utils' -export * from './types' -export { getDynamicPathParams } from './utils' +export * from './bracket-notation.ts' +export * from './client-factory.ts' +export { DEFAULT_OPENAPI_METHOD } from './constants.ts' +export * from './meta.ts' +export * from './openapi-generator.ts' +export * from './openapi-json-serializer.ts' +export * from './openapi-serializer.ts' +export * from './router-utils.ts' +export * from './types.ts' +export { getDynamicPathParams } from './utils.ts' -export { - COMMON_ERROR_STATUS_MAP, -} from '@orpc/client' +export { COMMON_ERROR_STATUS_MAP } from '@orpc/client' -export type { - JsonSchema, -} from '@orpc/json-schema' +export type { JsonSchema } from '@orpc/json-schema' -export type { - StandardBodyHint, -} from '@standard-server/core' +export type { StandardBodyHint } from '@standard-server/core' diff --git a/packages/openapi/src/meta.test.ts b/packages/openapi/src/meta.test.ts index b93a7268d2..048610cd2a 100644 --- a/packages/openapi/src/meta.test.ts +++ b/packages/openapi/src/meta.test.ts @@ -1,5 +1,6 @@ import { oc } from '@orpc/contract' -import { getOpenAPIMeta, openapi } from './meta' + +import { getOpenAPIMeta, openapi } from './meta.ts' describe('openapi meta', () => { describe('openapi() function', () => { @@ -50,9 +51,7 @@ describe('openapi meta', () => { }) it('uses only existing tags when no incoming tags', () => { - const procedure = oc - .meta(openapi({ tags: ['existing'] })) - .meta(openapi({})) + const procedure = oc.meta(openapi({ tags: ['existing'] })).meta(openapi({})) expect(getOpenAPIMeta(procedure)?.tags).toEqual(['existing']) }) }) @@ -80,9 +79,7 @@ describe('openapi meta', () => { }) it('keeps existing query styles when later call omits them', () => { - const procedure = oc - .meta(openapi({ queryStyles: { tags: 'array' } })) - .meta(openapi({})) + const procedure = oc.meta(openapi({ queryStyles: { tags: 'array' } })).meta(openapi({})) expect(getOpenAPIMeta(procedure)?.queryStyles).toEqual({ tags: 'array', @@ -132,23 +129,21 @@ describe('openapi meta', () => { it('uses existing object spec when no incoming spec', () => { const existingSpec = { operationId: 'listUsers' } - const procedure = oc - .meta(openapi({ spec: existingSpec })) - .meta(openapi({})) + 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 }) })) + .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: (current) => ({ ...current, fromExisting: true }) })) .meta(openapi({ spec: { operationId: 'override' } })) expect(getOpenAPIMeta(procedure)?.spec).toEqual({ operationId: 'override', @@ -159,7 +154,7 @@ describe('openapi meta', () => { 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 }) })) + .meta(openapi({ spec: (current) => ({ ...current, extra: true }) })) expect(getOpenAPIMeta(procedure)?.spec).toEqual({ operationId: 'base', extra: true, @@ -169,9 +164,7 @@ describe('openapi meta', () => { describe('prefix merging', () => { it('concatenates prefixes from multiple calls', () => { - const procedure = oc - .meta(openapi({ prefix: '/api' })) - .meta(openapi({ prefix: '/v1' })) + const procedure = oc.meta(openapi({ prefix: '/api' })).meta(openapi({ prefix: '/v1' })) expect(getOpenAPIMeta(procedure)?.prefix).toBe('/api/v1') }) @@ -181,16 +174,12 @@ describe('openapi meta', () => { }) it('uses only existing prefix when no incoming prefix', () => { - const procedure = oc - .meta(openapi({ prefix: '/api' })) - .meta(openapi({})) + 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/' })) + const procedure = oc.meta(openapi({ prefix: '/api/' })).meta(openapi({ prefix: '/v1/' })) expect(getOpenAPIMeta(procedure)?.prefix).toBe('/api/v1/') }) @@ -205,44 +194,48 @@ describe('openapi meta', () => { 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, - })) + .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({}) }) diff --git a/packages/openapi/src/meta.ts b/packages/openapi/src/meta.ts index 5de22a35e8..6aa8409b3a 100644 --- a/packages/openapi/src/meta.ts +++ b/packages/openapi/src/meta.ts @@ -1,9 +1,10 @@ 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 '@standard-server/core' -import type { OpenAPIV3_2 } from './types' import { mergeHttpPath } from '@orpc/shared' +import type { StandardBodyHint } from '@standard-server/core' + +import type { OpenAPIV3_2 } from './types.ts' export interface OpenAPIMeta { /** @@ -113,10 +114,9 @@ export interface OpenAPIMeta { * * @default `primitive` for all parameters */ - paramsStyles?: Record< - string, - 'primitive' | 'comma-delimited-array' | 'comma-delimited-object' | undefined - > | undefined + paramsStyles?: + | Record + | undefined /** * Controls how individual query parameters are encoding/decoding. @@ -169,10 +169,21 @@ export interface OpenAPIMeta { * * @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 + 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. @@ -367,39 +378,53 @@ export interface OpenAPIFunction { * @see {@link https://orpc.dev/docs/openapi/routing | OpenAPI Routing} * @see {@link https://orpc.dev/docs/openapi/specification | OpenAPI Specification} */ -export const openapi: OpenAPIFunction = incoming => ({ +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 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)) + 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 + : 'spec' in incoming + ? incomingSpec + : existingSpec - const prefix = existing?.prefix && incoming.prefix - ? mergeHttpPath(existing.prefix, incoming.prefix) - : 'prefix' in incoming ? incoming.prefix : existing?.prefix + 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 @@ -420,22 +445,22 @@ export const openapi: OpenAPIFunction = incoming => ({ }, }) -openapi.method = method => ({ +openapi.method = (method) => ({ ...openapi({ method }), name: '~openapi/method', }) -openapi.path = path => ({ +openapi.path = (path) => ({ ...openapi({ path }), name: '~openapi/path', }) -openapi.spec = spec => ({ +openapi.spec = (spec) => ({ ...openapi({ spec }), name: '~openapi/spec', }) -openapi.prefix = prefix => ({ +openapi.prefix = (prefix) => ({ ...openapi({ prefix }), name: '~openapi/prefix', }) @@ -445,6 +470,8 @@ openapi.prefix = prefix => ({ * * @see {@link https://orpc.dev/docs/rpc/handler#enabling-the-get-method | RPC Handler - Enabling the GET Method} */ -export function getOpenAPIMeta(procedureOrLazy: AnyProcedureContract | Lazy): OpenAPIMeta | undefined { +export function getOpenAPIMeta( + procedureOrLazy: AnyProcedureContract | Lazy, +): OpenAPIMeta | undefined { return procedureOrLazy['~orpc'].meta['~openapi'] as OpenAPIMeta | undefined } diff --git a/packages/openapi/src/openapi-generator-components.test.ts b/packages/openapi/src/openapi-generator-components.test.ts index 814c4ec07a..93d8024ee9 100644 --- a/packages/openapi/src/openapi-generator-components.test.ts +++ b/packages/openapi/src/openapi-generator-components.test.ts @@ -1,12 +1,15 @@ import type { JsonSchema } from '@orpc/json-schema' -import type { OpenAPIV3_2 } from './types' -import { OpenAPIComponentRegistry } from './openapi-generator-components' + +import { OpenAPIComponentRegistry } from './openapi-generator-components.ts' +import type { OpenAPIV3_2 } from './types.ts' describe('openAPIComponentRegistry', () => { - function createRegistry(options: { - schemas?: Record - componentName?: (defName: string, defSchema: JsonSchema) => string | undefined - } = {}) { + function createRegistry( + options: { + schemas?: Record + componentName?: (defName: string, defSchema: JsonSchema) => string | undefined + } = {}, + ) { const doc: OpenAPIV3_2.OpenAPIObject = { openapi: '3.2.0', info: { title: 'API Reference', version: '0.0.0' }, @@ -115,7 +118,10 @@ describe('openAPIComponentRegistry', () => { expect(result).toEqual({ $ref: '#/components/schemas/ApiPlanet' }) expect(doc.components?.schemas).toEqual({ - ApiPlanet: { type: 'object', properties: { moon: { $ref: '#/components/schemas/ApiMoon' } } }, + ApiPlanet: { + type: 'object', + properties: { moon: { $ref: '#/components/schemas/ApiMoon' } }, + }, ApiMoon: {}, }) @@ -126,7 +132,7 @@ describe('openAPIComponentRegistry', () => { it('keeps the def name when componentName returns undefined', () => { const { doc, registry } = createRegistry({ - componentName: defName => defName === 'Planet' ? 'World' : undefined, + componentName: (defName) => (defName === 'Planet' ? 'World' : undefined), }) const result = registry.hoistDefs({ @@ -195,10 +201,16 @@ describe('openAPIComponentRegistry', () => { it('hoists a def named __proto__ as an own component', () => { const { doc, registry } = createRegistry() - const result = registry.hoistDefs(JSON.parse('{"$ref":"#/$defs/__proto__","$defs":{"__proto__":{"type":"string"},"Planet":{"type":"number"}}}')) + const result = registry.hoistDefs( + JSON.parse( + '{"$ref":"#/$defs/__proto__","$defs":{"__proto__":{"type":"string"},"Planet":{"type":"number"}}}', + ), + ) expect(result).toEqual({ $ref: '#/components/schemas/__proto__' }) - expect(JSON.stringify(doc.components?.schemas)).toBe('{"__proto__":{"type":"string"},"Planet":{"type":"number"}}') + expect(JSON.stringify(doc.components?.schemas)).toBe( + '{"__proto__":{"type":"string"},"Planet":{"type":"number"}}', + ) }) it('does not mistake Object.prototype members for existing components', () => { @@ -261,7 +273,7 @@ describe('openAPIComponentRegistry', () => { }) expect(result).toEqual({ $ref: '#/components/schemas/Planet2' }) - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Planet', 'Planet2']) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual(['Planet', 'Planet2']) }) it.each([ @@ -307,9 +319,13 @@ describe('openAPIComponentRegistry', () => { }, } - expect(registry.hoistDefs(structuredClone(wrapper))).toEqual({ $ref: '#/components/schemas/Wrapper' }) - expect(registry.hoistDefs(structuredClone(wrapper))).toEqual({ $ref: '#/components/schemas/Wrapper' }) - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Data', 'Wrapper']) + expect(registry.hoistDefs(structuredClone(wrapper))).toEqual({ + $ref: '#/components/schemas/Wrapper', + }) + expect(registry.hoistDefs(structuredClone(wrapper))).toEqual({ + $ref: '#/components/schemas/Wrapper', + }) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual(['Data', 'Wrapper']) }) it.each([ @@ -339,7 +355,9 @@ describe('openAPIComponentRegistry', () => { }) expect(result).toEqual({ $ref: `#/components/schemas/${expected}` }) - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(Object.keys(schemas).sort()) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual( + Object.keys(schemas).toSorted(), + ) }) it('compares a ref to a sibling by its own body when the sibling reuses another def name', () => { @@ -362,7 +380,9 @@ describe('openAPIComponentRegistry', () => { }) expect(result).toEqual({ $ref: '#/components/schemas/Wrapper2' }) - expect(doc.components?.schemas?.Wrapper2).toEqual({ items: { $ref: '#/components/schemas/Post2' } }) + expect(doc.components?.schemas?.Wrapper2).toEqual({ + items: { $ref: '#/components/schemas/Post2' }, + }) }) it('reuses mutually recursive sibling defs', () => { @@ -408,7 +428,11 @@ describe('openAPIComponentRegistry', () => { }) expect(result).toEqual({ $ref: '#/components/schemas/User' }) - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Post', 'Post2', 'User']) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual([ + 'Post', + 'Post2', + 'User', + ]) expect(doc.components?.schemas?.Post).toEqual({ type: 'string' }) }) @@ -432,7 +456,11 @@ describe('openAPIComponentRegistry', () => { }) expect(result).toEqual({ $ref: '#/components/schemas/User' }) - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Post', 'Post2', 'User']) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual([ + 'Post', + 'Post2', + 'User', + ]) }) it('reuses sibling defs when a later sibling needs the family rename', () => { @@ -455,7 +483,11 @@ describe('openAPIComponentRegistry', () => { }) expect(result).toEqual({ $ref: '#/components/schemas/User2' }) - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Post', 'User', 'User2']) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual([ + 'Post', + 'User', + 'User2', + ]) }) it('handles shared subschema instances during traversal and comparison', () => { @@ -494,7 +526,7 @@ describe('openAPIComponentRegistry', () => { }) expect(result).toEqual({ $ref: '#/components/schemas/Pair' }) - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Leaf', 'Pair']) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual(['Leaf', 'Pair']) }) it('does not reuse components whose date defaults differ', () => { @@ -557,10 +589,18 @@ describe('openAPIComponentRegistry', () => { it('suffixes the conversion direction when the bare name is taken', () => { const { doc, registry } = createRegistry({ schemas: { Planet: { type: 'string' } } }) - expect(registry.hoistDefs({ $ref: '#/$defs/Planet', $defs: { Planet: { type: 'number' } } }, 'input')) - .toEqual({ $ref: '#/components/schemas/PlanetInput' }) - expect(registry.hoistDefs({ $ref: '#/$defs/Planet', $defs: { Planet: { type: 'boolean' } } }, 'output')) - .toEqual({ $ref: '#/components/schemas/PlanetOutput' }) + expect( + registry.hoistDefs( + { $ref: '#/$defs/Planet', $defs: { Planet: { type: 'number' } } }, + 'input', + ), + ).toEqual({ $ref: '#/components/schemas/PlanetInput' }) + expect( + registry.hoistDefs( + { $ref: '#/$defs/Planet', $defs: { Planet: { type: 'boolean' } } }, + 'output', + ), + ).toEqual({ $ref: '#/components/schemas/PlanetOutput' }) expect(doc.components?.schemas).toEqual({ Planet: { type: 'string' }, @@ -578,15 +618,26 @@ describe('openAPIComponentRegistry', () => { }, }) - expect(taken.registry.hoistDefs({ $ref: '#/$defs/Planet', $defs: { Planet: { type: 'number' } } }, 'output')) - .toEqual({ $ref: '#/components/schemas/PlanetInput' }) - expect(Object.keys(taken.doc.components?.schemas ?? {}).sort()).toEqual(['Planet', 'PlanetInput']) + expect( + taken.registry.hoistDefs( + { $ref: '#/$defs/Planet', $defs: { Planet: { type: 'number' } } }, + 'output', + ), + ).toEqual({ $ref: '#/components/schemas/PlanetInput' }) + expect(Object.keys(taken.doc.components?.schemas ?? {}).toSorted()).toEqual([ + 'Planet', + 'PlanetInput', + ]) // reuse even wins over minting the free bare name const free = createRegistry({ schemas: { PlanetInput: { type: 'number' } } }) - expect(free.registry.hoistDefs({ $ref: '#/$defs/Planet', $defs: { Planet: { type: 'number' } } }, 'output')) - .toEqual({ $ref: '#/components/schemas/PlanetInput' }) + expect( + free.registry.hoistDefs( + { $ref: '#/$defs/Planet', $defs: { Planet: { type: 'number' } } }, + 'output', + ), + ).toEqual({ $ref: '#/components/schemas/PlanetInput' }) expect(Object.keys(free.doc.components?.schemas ?? {})).toEqual(['PlanetInput']) }) @@ -599,14 +650,26 @@ describe('openAPIComponentRegistry', () => { }) // equal to the existing directed member, reused - expect(registry.hoistDefs({ $ref: '#/$defs/Planet', $defs: { Planet: { type: 'number' } } }, 'output')) - .toEqual({ $ref: '#/components/schemas/PlanetOutput' }) + expect( + registry.hoistDefs( + { $ref: '#/$defs/Planet', $defs: { Planet: { type: 'number' } } }, + 'output', + ), + ).toEqual({ $ref: '#/components/schemas/PlanetOutput' }) // different, takes the next slot in the numeric tail shared by every direction - expect(registry.hoistDefs({ $ref: '#/$defs/Planet', $defs: { Planet: { type: 'boolean' } } }, 'output')) - .toEqual({ $ref: '#/components/schemas/Planet2' }) + expect( + registry.hoistDefs( + { $ref: '#/$defs/Planet', $defs: { Planet: { type: 'boolean' } } }, + 'output', + ), + ).toEqual({ $ref: '#/components/schemas/Planet2' }) - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Planet', 'Planet2', 'PlanetOutput']) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual([ + 'Planet', + 'Planet2', + 'PlanetOutput', + ]) }) it('reuses numeric-tail slots across directions', () => { @@ -619,14 +682,27 @@ describe('openAPIComponentRegistry', () => { }) // an input variant fills the shared numeric tail - expect(registry.hoistDefs({ $ref: '#/$defs/Planet', $defs: { Planet: { type: 'boolean' } } }, 'input')) - .toEqual({ $ref: '#/components/schemas/Planet2' }) + expect( + registry.hoistDefs( + { $ref: '#/$defs/Planet', $defs: { Planet: { type: 'boolean' } } }, + 'input', + ), + ).toEqual({ $ref: '#/components/schemas/Planet2' }) // an equal output variant reuses it, no matter which direction registered it - expect(registry.hoistDefs({ $ref: '#/$defs/Planet', $defs: { Planet: { type: 'boolean' } } }, 'output')) - .toEqual({ $ref: '#/components/schemas/Planet2' }) + expect( + registry.hoistDefs( + { $ref: '#/$defs/Planet', $defs: { Planet: { type: 'boolean' } } }, + 'output', + ), + ).toEqual({ $ref: '#/components/schemas/Planet2' }) - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Planet', 'Planet2', 'PlanetInput', 'PlanetOutput']) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual([ + 'Planet', + 'Planet2', + 'PlanetInput', + 'PlanetOutput', + ]) }) it.each([ @@ -686,9 +762,11 @@ describe('openAPIComponentRegistry', () => { }) expect(result).toEqual({ $ref: '#/components/schemas/Wrapped2' }) - expect(doc.components?.schemas?.Wrapped2).toEqual(expect.objectContaining({ - properties: { x: { $ref: '#/components/schemas/Inner' } }, - })) + expect(doc.components?.schemas?.Wrapped2).toEqual( + expect.objectContaining({ + properties: { x: { $ref: '#/components/schemas/Inner' } }, + }), + ) }) it('distinguishes a self-recursive schema from mutually recursive components', () => { @@ -788,7 +866,10 @@ describe('openAPIComponentRegistry', () => { it('registers a schema and returns a component ref', () => { const { doc, registry } = createRegistry() - const result = registry.register('Planet', { type: 'object', properties: { id: { type: 'string' } } }) + const result = registry.register('Planet', { + type: 'object', + properties: { id: { type: 'string' } }, + }) expect(result).toEqual({ $ref: '#/components/schemas/Planet' }) expect(doc.components?.schemas).toEqual({ @@ -840,7 +921,7 @@ describe('openAPIComponentRegistry', () => { }) it('applies componentName to registered schemas', () => { - const { doc, registry } = createRegistry({ componentName: defName => `Api${defName}` }) + const { doc, registry } = createRegistry({ componentName: (defName) => `Api${defName}` }) const result = registry.register('Planet', { type: 'object' }) @@ -856,11 +937,13 @@ describe('openAPIComponentRegistry', () => { expect(registry.toOpenAPISchema(true)).toEqual({}) expect(registry.toOpenAPISchema(false)).toEqual({ not: {} }) - expect(registry.toOpenAPISchema({ - type: 'object', - properties: { planet: { $ref: '#/$defs/Planet' } }, - $defs: { Planet: { type: 'string' } }, - })).toEqual({ + expect( + registry.toOpenAPISchema({ + type: 'object', + properties: { planet: { $ref: '#/$defs/Planet' } }, + $defs: { Planet: { type: 'string' } }, + }), + ).toEqual({ type: 'object', properties: { planet: { $ref: '#/components/schemas/Planet' } }, }) diff --git a/packages/openapi/src/openapi-generator-components.ts b/packages/openapi/src/openapi-generator-components.ts index a4f264997f..56b8bd663d 100644 --- a/packages/openapi/src/openapi-generator-components.ts +++ b/packages/openapi/src/openapi-generator-components.ts @@ -1,5 +1,4 @@ import type { JsonSchema, JsonSchemaConverterDirection } from '@orpc/json-schema' -import type { OpenAPIV3_2 } from './types' import { decodeJsonPointerSegment, encodeJsonPointerSegment, @@ -8,6 +7,8 @@ import { } from '@orpc/json-schema' import { getOwn, isDeepEqual, isPlainObject, setOwn } from '@orpc/shared' +import type { OpenAPIV3_2 } from './types.ts' + const DEFS_REF_PREFIX = '#/$defs/' const COMPONENTS_REF_PREFIX = '#/components/schemas/' @@ -20,7 +21,9 @@ const COMPONENTS_REF_PREFIX = '#/components/schemas/' export class OpenAPIComponentRegistry { constructor( private readonly doc: OpenAPIV3_2.OpenAPIObject, - private readonly componentName: ((defName: string, defSchema: JsonSchema) => string | undefined) | undefined, + private readonly componentName: + | ((defName: string, defSchema: JsonSchema) => string | undefined) + | undefined, ) {} /** @@ -73,7 +76,8 @@ export class OpenAPIComponentRegistry { const componentsSchemas = this.doc.components.schemas const renameMap = new Map() - const pendingSchemas: { cleanSchema: Exclude, componentName: string }[] = [] + const pendingSchemas: { cleanSchema: Exclude; componentName: string }[] = + [] for (const [defName, cleanSchema] of defs) { const [componentName, reuseExisting] = resolveComponentName( @@ -99,7 +103,10 @@ export class OpenAPIComponentRegistry { return rewriteComponentSchemaRefs(rest, renameMap) } - toOpenAPISchema(schema: JsonSchema, direction?: JsonSchemaConverterDirection): OpenAPIV3_2.SchemaObject { + toOpenAPISchema( + schema: JsonSchema, + direction?: JsonSchemaConverterDirection, + ): OpenAPIV3_2.SchemaObject { return ensureJsonSchemaObject(this.hoistDefs(schema, direction)) } } @@ -141,11 +148,19 @@ function resolveComponentName( continue } - if (areSchemaRefsEquivalentForReuse( - DEFS_REF_PREFIX + encodeJsonPointerSegment(defName), - COMPONENTS_REF_PREFIX + encodeJsonPointerSegment(componentName), - { defs, componentsSchemas, candidateToExistingKeys: new Map(), pairedExistingKeys: new Set(), visited: new WeakMap() }, - )) { + if ( + areSchemaRefsEquivalentForReuse( + DEFS_REF_PREFIX + encodeJsonPointerSegment(defName), + COMPONENTS_REF_PREFIX + encodeJsonPointerSegment(componentName), + { + defs, + componentsSchemas, + candidateToExistingKeys: new Map(), + pairedExistingKeys: new Set(), + visited: new WeakMap(), + }, + ) + ) { return [componentName, true] } } @@ -179,7 +194,12 @@ function componentNameCandidate( function definedKeysOf(object: Record): string[] { // `undefined`-valued keys (e.g. `default: undefined`) are stripped during serialization, // so they must not affect equivalence against components from serialized documents - return Object.keys(object).filter(key => object[key] !== undefined).sort() + return ( + Object.keys(object) + .filter((key) => object[key] !== undefined) + // oxlint-disable-next-line unicorn/no-array-sort + .sort() + ) } interface ReuseComparisonContext { @@ -195,7 +215,11 @@ function isSchemaContainer(value: unknown): value is unknown[] | Record areSchemasEquivalentForReuse(item, existing[index], ctx)) + return candidate.every((item, index) => + areSchemasEquivalentForReuse(item, existing[index], ctx), + ) } const candidateObject = candidate as Record @@ -259,7 +288,7 @@ function resolveNamedRef( ref: string, prefix: string, getNamed: (name: string) => unknown, -): { key: string, schema: JsonSchema } | undefined { +): { key: string; schema: JsonSchema } | undefined { const name = parseRefName(ref, prefix) if (name === undefined) { @@ -271,10 +300,15 @@ function resolveNamedRef( return schema === undefined ? undefined : { key: prefix + name, schema } } -function areSchemaRefsEquivalentForReuse(candidateRef: string, existingRef: string, ctx: ReuseComparisonContext): boolean { +function areSchemaRefsEquivalentForReuse( + candidateRef: string, + existingRef: string, + ctx: ReuseComparisonContext, +): boolean { const getComponent = (name: string) => getOwn(ctx.componentsSchemas, name) - const candidate = resolveNamedRef(candidateRef, DEFS_REF_PREFIX, name => ctx.defs.get(name)) - ?? resolveNamedRef(candidateRef, COMPONENTS_REF_PREFIX, getComponent) + const candidate = + resolveNamedRef(candidateRef, DEFS_REF_PREFIX, (name) => ctx.defs.get(name)) ?? + resolveNamedRef(candidateRef, COMPONENTS_REF_PREFIX, getComponent) const existing = resolveNamedRef(existingRef, COMPONENTS_REF_PREFIX, getComponent) if (candidate === undefined || existing === undefined) { @@ -295,7 +329,10 @@ function areSchemaRefsEquivalentForReuse(candidateRef: string, existingRef: stri return areSchemasEquivalentForReuse(candidate.schema, existing.schema, ctx) } -function rewriteComponentSchemaRefs(schema: JsonSchema, renameMap: ReadonlyMap): JsonSchema { +function rewriteComponentSchemaRefs( + schema: JsonSchema, + renameMap: ReadonlyMap, +): JsonSchema { return mapJsonSchemaRefs(schema, (ref) => { const refName = parseRefName(ref, DEFS_REF_PREFIX) diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index e37172b544..c7f60d6a9a 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -1,27 +1,29 @@ -import type { AnyProcedureContract, AnySchema, ErrorMap } from '@orpc/contract' -import type { OpenAPIOperationContext } from './openapi-generator-operation' -import type { OpenAPIV3_2 } from './types' import { COMMON_ERROR_STATUS_MAP } from '@orpc/client' +import type { AnyProcedureContract, AnySchema, ErrorMap } from '@orpc/contract' import { asyncIteratorObject, error } from '@orpc/contract' import { combineJsonSchemasWithComposition, DelegatingJsonSchemaConverter } from '@orpc/json-schema' -import { testSchema, testSchemaConverter } from '../tests/__shared__/schema' -import { OpenAPIComponentRegistry } from './openapi-generator-components' + +import { testSchema, testSchemaConverter } from '../tests/__shared__/schema.ts' +import { OpenAPIComponentRegistry } from './openapi-generator-components.ts' +import type { OpenAPIOperationContext } from './openapi-generator-operation.ts' import { buildErrorResponse, buildRequest, buildSuccessResponse, toOpenAPIPath, -} from './openapi-generator-operation' - -import { getDynamicPathParams } from './utils' +} from './openapi-generator-operation.ts' +import type { OpenAPIV3_2 } from './types.ts' +import { getDynamicPathParams } from './utils.ts' describe('openAPIGenerator operation builders', () => { - function createContext(options: { - schemas?: Record - errorStatusMap?: Record - errorResponseBodySchema?: OpenAPIOperationContext['errorResponseBodySchema'] - errorResponseContentType?: string - } = {}) { + function createContext( + options: { + schemas?: Record + errorStatusMap?: Record + errorResponseBodySchema?: OpenAPIOperationContext['errorResponseBodySchema'] + errorResponseContentType?: string + } = {}, + ) { const doc: OpenAPIV3_2.OpenAPIObject = { openapi: '3.2.0', info: { title: 'API Reference', version: '0.0.0' }, @@ -37,10 +39,13 @@ describe('openAPIGenerator operation builders', () => { return converter.convert(schemas?.[0], direction) } - const results = schemas.map(s => converter.convert(s, direction)) + const results = schemas.map((s) => converter.convert(s, direction)) return [ - combineJsonSchemasWithComposition('allOf', results.map(([jsonSchema]) => jsonSchema)), + combineJsonSchemasWithComposition( + 'allOf', + results.map(([jsonSchema]) => jsonSchema), + ), results.every(([, optional]) => optional), ] }, @@ -54,7 +59,9 @@ describe('openAPIGenerator operation builders', () => { return { doc, ctx, operation } } - function testDef(def: { inputs?: AnySchema[], outputs?: AnySchema[], errors?: ErrorMap } = {}): AnyProcedureContract['~orpc'] { + function testDef( + def: { inputs?: AnySchema[]; outputs?: AnySchema[]; errors?: ErrorMap } = {}, + ): AnyProcedureContract['~orpc'] { return { inputSchemas: def.inputs, outputSchemas: def.outputs, @@ -86,16 +93,32 @@ describe('openAPIGenerator operation builders', () => { it('maps compact inputs to a request body', () => { const { ctx, operation } = createContext() - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] })], - }), undefined, undefined) + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + testSchema({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }), + ], + }), + undefined, + undefined, + ) expect(operation).toEqual({ requestBody: { required: true, content: { 'application/json': { - schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + schema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, }, }, }, @@ -105,9 +128,15 @@ describe('openAPIGenerator operation builders', () => { it('omits required for optional compact request bodies', () => { const { ctx, operation } = createContext() - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ type: 'object' }, { optional: true })], - }), undefined, undefined) + buildRequest( + ctx, + operation, + testDef({ + inputs: [testSchema({ type: 'object' }, { optional: true })], + }), + undefined, + undefined, + ) expect(operation.requestBody).toEqual({ content: { 'application/json': { schema: { type: 'object' } } }, @@ -118,18 +147,26 @@ describe('openAPIGenerator operation builders', () => { const { ctx, operation } = createContext() const path = '/planets/{id}' as const - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - note: { type: 'string' }, - }, - required: ['id', 'name'], - additionalProperties: false, - })], - }), { method: 'POST', path }, getDynamicPathParams(path)) + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + testSchema({ + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + note: { type: 'string' }, + }, + required: ['id', 'name'], + additionalProperties: false, + }), + ], + }), + { method: 'POST', path }, + getDynamicPathParams(path), + ) expect(operation.parameters).toEqual([ { in: 'path', required: true, name: 'id', schema: { type: 'string' } }, @@ -156,30 +193,52 @@ describe('openAPIGenerator operation builders', () => { const { ctx, operation } = createContext() const path = '/planets/{id}/{tags}/{filters}' as const - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ - type: 'object', - properties: { - id: { type: 'string' }, - tags: { type: 'array' }, - filters: { type: 'object' }, + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + testSchema({ + type: 'object', + properties: { + id: { type: 'string' }, + tags: { type: 'array' }, + filters: { type: 'object' }, + }, + required: ['id', 'tags', 'filters'], + }), + ], + }), + { + method: 'GET', + path, + paramsStyles: { + id: 'primitive', + tags: 'comma-delimited-array', + filters: 'comma-delimited-object', }, - required: ['id', 'tags', 'filters'], - })], - }), { - method: 'GET', - path, - paramsStyles: { - id: 'primitive', - tags: 'comma-delimited-array', - filters: 'comma-delimited-object', }, - }, getDynamicPathParams(path)) + getDynamicPathParams(path), + ) expect(operation.parameters).toEqual([ { in: 'path', required: true, name: 'id', schema: { type: 'string' } }, - { in: 'path', required: true, name: 'tags', style: 'simple', explode: false, schema: { type: 'array' } }, - { in: 'path', required: true, name: 'filters', style: 'simple', explode: false, schema: { type: 'object' } }, + { + in: 'path', + required: true, + name: 'tags', + style: 'simple', + explode: false, + schema: { type: 'array' }, + }, + { + in: 'path', + required: true, + name: 'filters', + style: 'simple', + explode: false, + schema: { type: 'object' }, + }, ]) }) @@ -187,13 +246,21 @@ describe('openAPIGenerator operation builders', () => { const { ctx, operation } = createContext() const path = '/planets/{id}' as const - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ - type: 'object', - properties: { id: { type: 'string' }, note: { type: 'string' } }, - required: ['id'], - })], - }), { method: 'POST', path }, getDynamicPathParams(path)) + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + testSchema({ + type: 'object', + properties: { id: { type: 'string' }, note: { type: 'string' } }, + required: ['id'], + }), + ], + }), + { method: 'POST', path }, + getDynamicPathParams(path), + ) expect((operation.requestBody as any)?.required).toBeUndefined() }) @@ -201,44 +268,129 @@ describe('openAPIGenerator operation builders', () => { it('maps compact GET inputs to query parameters and applies every query style', () => { const { ctx, operation } = createContext() - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ - type: 'object', - properties: { - primitive: { type: 'string' }, - array: { type: 'array' }, - commaArray: { type: 'array' }, - pipeObject: { type: 'object' }, - spaceArray: { type: 'array' }, - json: { type: 'object' }, - deepObject: { type: 'object' }, - arrayable: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, - optional: { type: 'string' }, + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + testSchema({ + type: 'object', + properties: { + primitive: { type: 'string' }, + array: { type: 'array' }, + commaArray: { type: 'array' }, + pipeObject: { type: 'object' }, + spaceArray: { type: 'array' }, + json: { type: 'object' }, + deepObject: { type: 'object' }, + arrayable: { + anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }], + }, + optional: { type: 'string' }, + }, + required: [ + 'primitive', + 'array', + 'commaArray', + 'pipeObject', + 'spaceArray', + 'json', + 'deepObject', + 'arrayable', + ], + }), + ], + }), + { + method: 'GET', + queryStyles: { + primitive: 'primitive', + array: 'array', + commaArray: 'comma-delimited-array', + pipeObject: 'pipe-delimited-object', + spaceArray: 'space-delimited-array', + json: 'json', }, - required: ['primitive', 'array', 'commaArray', 'pipeObject', 'spaceArray', 'json', 'deepObject', 'arrayable'], - })], - }), { - method: 'GET', - queryStyles: { - primitive: 'primitive', - array: 'array', - commaArray: 'comma-delimited-array', - pipeObject: 'pipe-delimited-object', - spaceArray: 'space-delimited-array', - json: 'json', }, - }, undefined) + undefined, + ) expect(operation.parameters).toEqual([ - { in: 'query', name: 'primitive', required: true, allowEmptyValue: true, allowReserved: true, schema: { type: 'string' } }, - { in: 'query', name: 'array', required: true, allowEmptyValue: true, allowReserved: true, schema: { type: 'array' } }, - { in: 'query', name: 'commaArray', required: true, explode: false, allowEmptyValue: true, allowReserved: true, schema: { type: 'array' } }, - { in: 'query', name: 'pipeObject', required: true, style: 'pipeDelimited', allowEmptyValue: true, allowReserved: true, schema: { type: 'object' } }, - { in: 'query', name: 'spaceArray', required: true, style: 'spaceDelimited', allowEmptyValue: true, allowReserved: true, schema: { type: 'array' } }, - { in: 'query', name: 'json', required: true, allowEmptyValue: true, content: { 'application/json': { schema: { type: 'object' } } } }, - { in: 'query', name: 'deepObject', required: true, style: 'deepObject', explode: true, allowEmptyValue: true, allowReserved: true, schema: { type: 'object' } }, - { in: 'query', name: 'arrayable', required: true, allowEmptyValue: true, allowReserved: true, schema: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] } }, - { in: 'query', name: 'optional', allowEmptyValue: true, allowReserved: true, schema: { type: 'string' } }, + { + in: 'query', + name: 'primitive', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'string' }, + }, + { + in: 'query', + name: 'array', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'array' }, + }, + { + in: 'query', + name: 'commaArray', + required: true, + explode: false, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'array' }, + }, + { + in: 'query', + name: 'pipeObject', + required: true, + style: 'pipeDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'object' }, + }, + { + in: 'query', + name: 'spaceArray', + required: true, + style: 'spaceDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'array' }, + }, + { + in: 'query', + name: 'json', + required: true, + allowEmptyValue: true, + content: { 'application/json': { schema: { type: 'object' } } }, + }, + { + in: 'query', + name: 'deepObject', + required: true, + style: 'deepObject', + explode: true, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'object' }, + }, + { + in: 'query', + name: 'arrayable', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, + }, + { + in: 'query', + name: 'optional', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'string' }, + }, ]) expect(operation.requestBody).toBeUndefined() }) @@ -246,27 +398,47 @@ describe('openAPIGenerator operation builders', () => { it('does not use deepObject for query params whose $ref resolves to a primitive', () => { const { ctx, operation } = createContext() - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ - type: 'object', - properties: { - status: { $ref: '#/$defs/Status' }, - statuses: { anyOf: [{ $ref: '#/$defs/Status' }, { type: 'array', items: { $ref: '#/$defs/Status' } }] }, - filter: { $ref: '#/$defs/Filter' }, - filters: { anyOf: [{ $ref: '#/$defs/Filter' }, { type: 'array', items: { $ref: '#/$defs/Filter' } }] }, - }, - required: ['status', 'statuses', 'filter', 'filters'], - $defs: { - Status: { type: 'string', enum: ['a', 'b'] }, - Filter: { type: 'object', properties: { status: { $ref: '#/$defs/Status' } } }, - }, - })], - }), { method: 'GET' }, undefined) + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + testSchema({ + type: 'object', + properties: { + status: { $ref: '#/$defs/Status' }, + statuses: { + anyOf: [ + { $ref: '#/$defs/Status' }, + { type: 'array', items: { $ref: '#/$defs/Status' } }, + ], + }, + filter: { $ref: '#/$defs/Filter' }, + filters: { + anyOf: [ + { $ref: '#/$defs/Filter' }, + { type: 'array', items: { $ref: '#/$defs/Filter' } }, + ], + }, + }, + required: ['status', 'statuses', 'filter', 'filters'], + $defs: { + Status: { type: 'string', enum: ['a', 'b'] }, + Filter: { type: 'object', properties: { status: { $ref: '#/$defs/Status' } } }, + }, + }), + ], + }), + { method: 'GET' }, + undefined, + ) - expect(operation.parameters?.map((p) => { - const { name, style, explode } = p as OpenAPIV3_2.ParameterObject - return { name, style, explode } - })).toEqual([ + expect( + operation.parameters?.map((p) => { + const { name, style, explode } = p as OpenAPIV3_2.ParameterObject + return { name, style, explode } + }), + ).toEqual([ { name: 'status', style: undefined, explode: undefined }, { name: 'statuses', style: undefined, explode: undefined }, { name: 'filter', style: 'deepObject', explode: true }, @@ -278,17 +450,32 @@ describe('openAPIGenerator operation builders', () => { const { ctx, operation } = createContext() const path = '/planets/{id}' as const - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ - type: 'object', - properties: { id: { type: 'string' }, verbose: { type: 'boolean' } }, - required: ['id', 'verbose'], - })], - }), { method: 'HEAD', path }, getDynamicPathParams(path)) + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + testSchema({ + type: 'object', + properties: { id: { type: 'string' }, verbose: { type: 'boolean' } }, + required: ['id', 'verbose'], + }), + ], + }), + { method: 'HEAD', path }, + getDynamicPathParams(path), + ) expect(operation.parameters).toEqual([ { in: 'path', required: true, name: 'id', schema: { type: 'string' } }, - { in: 'query', name: 'verbose', required: true, allowEmptyValue: true, allowReserved: true, schema: { type: 'boolean' } }, + { + in: 'query', + name: 'verbose', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'boolean' }, + }, ]) expect(operation.requestBody).toBeUndefined() }) @@ -297,22 +484,47 @@ describe('openAPIGenerator operation builders', () => { const { ctx, operation } = createContext() const path = '/planets/{id}' as const - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ - type: 'object', - properties: { - params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, - query: { type: 'object', properties: { expand: { type: 'boolean' } } }, - headers: { type: 'object', properties: { 'x-trace-id': { type: 'string' }, 'x-optional': { type: 'string' } }, required: ['x-trace-id'] }, - body: { type: 'object', properties: { name: { type: 'string' } } }, - }, - required: ['params', 'headers'], - })], - }), { inputStructure: 'detailed', path }, getDynamicPathParams(path)) + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + testSchema({ + type: 'object', + properties: { + params: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + query: { type: 'object', properties: { expand: { type: 'boolean' } } }, + headers: { + type: 'object', + properties: { + 'x-trace-id': { type: 'string' }, + 'x-optional': { type: 'string' }, + }, + required: ['x-trace-id'], + }, + body: { type: 'object', properties: { name: { type: 'string' } } }, + }, + required: ['params', 'headers'], + }), + ], + }), + { inputStructure: 'detailed', path }, + getDynamicPathParams(path), + ) expect(operation.parameters).toEqual([ { in: 'path', required: true, name: 'id', schema: { type: 'string' } }, - { in: 'query', name: 'expand', allowEmptyValue: true, allowReserved: true, schema: { type: 'boolean' } }, + { + in: 'query', + name: 'expand', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'boolean' }, + }, { in: 'header', name: 'x-trace-id', required: true, schema: { type: 'string' } }, { in: 'header', name: 'x-optional', schema: { type: 'string' } }, ]) @@ -328,13 +540,21 @@ describe('openAPIGenerator operation builders', () => { it('maps detailed inputs with only a body section', () => { const { ctx, operation } = createContext() - buildRequest(ctx, operation, testDef({ - inputs: [testSchema({ - type: 'object', - properties: { body: { type: 'string' } }, - required: ['body'], - })], - }), { inputStructure: 'detailed' }, undefined) + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + testSchema({ + type: 'object', + properties: { body: { type: 'string' } }, + required: ['body'], + }), + ], + }), + { inputStructure: 'detailed' }, + undefined, + ) expect(operation.parameters).toBeUndefined() expect(operation.requestBody).toEqual({ @@ -346,9 +566,17 @@ describe('openAPIGenerator operation builders', () => { it('maps AsyncIteratorObject inputs to an SSE request body', () => { const { ctx, operation } = createContext() - buildRequest(ctx, operation, testDef({ - inputs: [asyncIteratorObject(testSchema({ type: 'string' }), testSchema({ type: 'boolean' }))], - }), undefined, undefined) + buildRequest( + ctx, + operation, + testDef({ + inputs: [ + asyncIteratorObject(testSchema({ type: 'string' }), testSchema({ type: 'boolean' })), + ], + }), + undefined, + undefined, + ) expect(operation.requestBody).toEqual({ required: true, @@ -396,11 +624,19 @@ describe('openAPIGenerator operation builders', () => { it('maps AsyncIteratorObject inputs without a return schema', () => { const { ctx, operation } = createContext() - buildRequest(ctx, operation, testDef({ - inputs: [asyncIteratorObject(testSchema({ type: 'string' }))], - }), undefined, undefined) + buildRequest( + ctx, + operation, + testDef({ + inputs: [asyncIteratorObject(testSchema({ type: 'string' }))], + }), + undefined, + undefined, + ) - expect(((operation.requestBody as any)?.content?.['text/event-stream']?.schema as any).oneOf[1]).toEqual({ + expect( + ((operation.requestBody as any)?.content?.['text/event-stream']?.schema as any).oneOf[1], + ).toEqual({ type: 'object', properties: { event: { const: 'close' }, @@ -443,13 +679,19 @@ describe('openAPIGenerator operation builders', () => { }, { name: 'a dynamic param is missing from the input schema', - inputs: [testSchema({ - type: 'object', - properties: { - params: { type: 'object', properties: { other: { type: 'string' } }, required: ['other'] }, - }, - required: ['params'], - })], + inputs: [ + testSchema({ + type: 'object', + properties: { + params: { + type: 'object', + properties: { other: { type: 'string' } }, + required: ['other'], + }, + }, + required: ['params'], + }), + ], meta: { inputStructure: 'detailed' as const, path: '/planets/{id}' as const }, path: '/planets/{id}' as const, message: 'dynamic param "{id}" is missing from the input schema', @@ -478,13 +720,15 @@ describe('openAPIGenerator operation builders', () => { ])('throws when $name', ({ inputs, meta, path, message }) => { const { ctx, operation } = createContext() - expect(() => buildRequest( - ctx, - operation, - testDef({ inputs }), - meta, - path ? getDynamicPathParams(path) : undefined, - )).toThrow(message) + expect(() => + buildRequest( + ctx, + operation, + testDef({ inputs }), + meta, + path ? getDynamicPathParams(path) : undefined, + ), + ).toThrow(message) }) }) @@ -502,9 +746,14 @@ describe('openAPIGenerator operation builders', () => { it('maps compact outputs with custom status and description', () => { const { ctx, operation } = createContext() - buildSuccessResponse(ctx, operation, testDef({ - outputs: [testSchema({ type: 'object' })], - }), { successStatus: 201, successDescription: 'Created' }) + buildSuccessResponse( + ctx, + operation, + testDef({ + outputs: [testSchema({ type: 'object' })], + }), + { successStatus: 201, successDescription: 'Created' }, + ) expect(operation.responses).toEqual({ 201: { @@ -517,16 +766,23 @@ describe('openAPIGenerator operation builders', () => { it('splits file outputs by media type', () => { const { ctx, operation } = createContext() - buildSuccessResponse(ctx, operation, testDef({ - outputs: [testSchema({ - anyOf: [ - { type: 'string', contentMediaType: 'image/png', contentEncoding: 'binary' }, - { type: 'string', contentMediaType: 'image/png', contentEncoding: 'base64' }, - { type: 'string', contentEncoding: 'binary' }, - { type: 'object' }, + buildSuccessResponse( + ctx, + operation, + testDef({ + outputs: [ + testSchema({ + anyOf: [ + { type: 'string', contentMediaType: 'image/png', contentEncoding: 'binary' }, + { type: 'string', contentMediaType: 'image/png', contentEncoding: 'base64' }, + { type: 'string', contentEncoding: 'binary' }, + { type: 'object' }, + ], + }), ], - })], - }), undefined) + }), + undefined, + ) expect(operation.responses?.['200']).toEqual({ description: 'OK', @@ -548,12 +804,19 @@ describe('openAPIGenerator operation builders', () => { it('uses multipart/form-data for bodies with nested files', () => { const { ctx, operation } = createContext() - buildSuccessResponse(ctx, operation, testDef({ - outputs: [testSchema({ - type: 'object', - properties: { file: { type: 'string', contentEncoding: 'binary' } }, - })], - }), undefined) + buildSuccessResponse( + ctx, + operation, + testDef({ + outputs: [ + testSchema({ + type: 'object', + properties: { file: { type: 'string', contentEncoding: 'binary' } }, + }), + ], + }), + undefined, + ) expect(operation.responses?.['200']).toEqual({ description: 'OK', @@ -571,22 +834,36 @@ describe('openAPIGenerator operation builders', () => { it('reads the content media type of a file body from its $ref target', () => { const { ctx, operation } = createContext() - buildSuccessResponse(ctx, operation, testDef({ - outputs: [testSchema({ - anyOf: [{ $ref: '#/$defs/Image' }, { type: 'object' }], - $defs: { Image: { type: 'string', contentMediaType: 'image/png' } }, - })], - }), undefined) + buildSuccessResponse( + ctx, + operation, + testDef({ + outputs: [ + testSchema({ + anyOf: [{ $ref: '#/$defs/Image' }, { type: 'object' }], + $defs: { Image: { type: 'string', contentMediaType: 'image/png' } }, + }), + ], + }), + undefined, + ) - expect(Object.keys((operation.responses?.['200'] as OpenAPIV3_2.ResponseObject).content ?? {})).toEqual(['application/json', 'image/png']) + expect( + Object.keys((operation.responses?.['200'] as OpenAPIV3_2.ResponseObject).content ?? {}), + ).toEqual(['application/json', 'image/png']) }) it('maps AsyncIteratorObject outputs to an SSE success response', () => { const { ctx, operation } = createContext() - buildSuccessResponse(ctx, operation, testDef({ - outputs: [asyncIteratorObject(testSchema({ type: 'string' }))], - }), undefined) + buildSuccessResponse( + ctx, + operation, + testDef({ + outputs: [asyncIteratorObject(testSchema({ type: 'string' }))], + }), + undefined, + ) expect(operation.responses?.['200']).toEqual({ description: 'OK', @@ -601,44 +878,55 @@ describe('openAPIGenerator operation builders', () => { it('maps detailed outputs to per-status responses with merged descriptions, bodies, and headers', () => { const { ctx, operation } = createContext() - buildSuccessResponse(ctx, operation, testDef({ - outputs: [testSchema({ - anyOf: [ - { - type: 'object', - properties: { - status: { const: 201, description: 'created' }, - headers: { type: 'object', properties: { 'x-id': { type: 'string' } }, required: ['x-id'] }, - body: { type: 'object' }, - }, - required: ['status', 'headers', 'body'], - }, - { - type: 'object', - properties: { - status: { const: 201 }, - body: { type: 'string' }, - }, - required: ['status', 'body'], - }, - { - type: 'object', - properties: { - headers: { type: 'object', properties: { 'x-opt': { type: 'string' } } }, - }, - required: [], - }, - { - type: 'object', - properties: { - status: { const: 202 }, - body: { type: 'boolean' }, - }, - required: ['status', 'body'], - }, + buildSuccessResponse( + ctx, + operation, + testDef({ + outputs: [ + testSchema({ + anyOf: [ + { + type: 'object', + properties: { + status: { const: 201, description: 'created' }, + headers: { + type: 'object', + properties: { 'x-id': { type: 'string' } }, + required: ['x-id'], + }, + body: { type: 'object' }, + }, + required: ['status', 'headers', 'body'], + }, + { + type: 'object', + properties: { + status: { const: 201 }, + body: { type: 'string' }, + }, + required: ['status', 'body'], + }, + { + type: 'object', + properties: { + headers: { type: 'object', properties: { 'x-opt': { type: 'string' } } }, + }, + required: [], + }, + { + type: 'object', + properties: { + status: { const: 202 }, + body: { type: 'boolean' }, + }, + required: ['status', 'body'], + }, + ], + }), ], - })], - }), { outputStructure: 'detailed', successStatus: 299 }) + }), + { outputStructure: 'detailed', successStatus: 299 }, + ) expect(operation.responses).toEqual({ 201: { @@ -672,28 +960,35 @@ describe('openAPIGenerator operation builders', () => { it('also documents the success status when the detailed status is optional', () => { const { ctx, operation } = createContext() - buildSuccessResponse(ctx, operation, testDef({ - outputs: [testSchema({ - anyOf: [ - { - type: 'object', - properties: { - status: { const: 201, description: 'created' }, - body: { type: 'string' }, - }, - required: ['body'], - }, - { - type: 'object', - properties: { - status: { type: 'number', enum: [202, 299] }, - body: { type: 'boolean' }, - }, - required: ['body'], - }, + buildSuccessResponse( + ctx, + operation, + testDef({ + outputs: [ + testSchema({ + anyOf: [ + { + type: 'object', + properties: { + status: { const: 201, description: 'created' }, + body: { type: 'string' }, + }, + required: ['body'], + }, + { + type: 'object', + properties: { + status: { type: 'number', enum: [202, 299] }, + body: { type: 'boolean' }, + }, + required: ['body'], + }, + ], + }), ], - })], - }), { outputStructure: 'detailed', successStatus: 299 }) + }), + { outputStructure: 'detailed', successStatus: 299 }, + ) expect(operation.responses).toEqual({ 201: { @@ -706,7 +1001,9 @@ describe('openAPIGenerator operation builders', () => { }, 299: { description: 'OK', - content: { 'application/json': { schema: { anyOf: [{ type: 'string' }, { type: 'boolean' }] } } }, + content: { + 'application/json': { schema: { anyOf: [{ type: 'string' }, { type: 'boolean' }] } }, + }, }, }) }) @@ -739,32 +1036,49 @@ describe('openAPIGenerator operation builders', () => { ])('maps $name detailed status to per-status responses', ({ status, descriptions }) => { const { ctx, operation } = createContext() - buildSuccessResponse(ctx, operation, testDef({ - outputs: [testSchema({ - type: 'object', - properties: { - status: status as any, - headers: { type: 'object', properties: { 'x-id': { type: 'string' } }, required: ['x-id'] }, - body: { type: 'string' }, - }, - required: ['status', 'headers', 'body'], - })], - }), { outputStructure: 'detailed' }) - - expect(operation.responses).toEqual(Object.fromEntries( - Object.entries(descriptions).map(([code, description]) => [code, { - description, - headers: { 'x-id': { required: true, schema: { type: 'string' } } }, - content: { 'application/json': { schema: { type: 'string' } } }, - }]), - )) + buildSuccessResponse( + ctx, + operation, + testDef({ + outputs: [ + testSchema({ + type: 'object', + properties: { + status: status as any, + headers: { + type: 'object', + properties: { 'x-id': { type: 'string' } }, + required: ['x-id'], + }, + body: { type: 'string' }, + }, + required: ['status', 'headers', 'body'], + }), + ], + }), + { outputStructure: 'detailed' }, + ) + + expect(operation.responses).toEqual( + Object.fromEntries( + Object.entries(descriptions).map(([code, description]) => [ + code, + { + description, + headers: { 'x-id': { required: true, schema: { type: 'string' } } }, + content: { 'application/json': { schema: { type: 'string' } } }, + }, + ]), + ), + ) }) it.each([ { name: 'a detailed output member is not an object', output: { anyOf: [{ type: 'object' }, { type: 'string' }] }, - message: 'outputStructure is "detailed" but the output schema (or one of its union members) is not an object', + message: + 'outputStructure is "detailed" but the output schema (or one of its union members) is not an object', }, { name: 'a detailed status is not a schema object', @@ -773,7 +1087,11 @@ describe('openAPIGenerator operation builders', () => { }, { name: 'a detailed status is not a literal integer', - output: { type: 'object', properties: { status: { type: 'number' } }, required: ['status'] }, + output: { + type: 'object', + properties: { status: { type: 'number' } }, + required: ['status'], + }, message: 'invalid "status" field in the detailed output schema', }, { @@ -783,12 +1101,20 @@ describe('openAPIGenerator operation builders', () => { }, { name: 'a detailed enum status contains a non-success status', - output: { type: 'object', properties: { status: { type: 'number', enum: [200, 400] } }, required: ['status'] }, + output: { + type: 'object', + properties: { status: { type: 'number', enum: [200, 400] } }, + required: ['status'], + }, message: 'invalid "status" field in the detailed output schema', }, { name: 'a detailed enum status is empty', - output: { type: 'object', properties: { status: { type: 'number', enum: [] } }, required: ['status'] }, + output: { + type: 'object', + properties: { status: { type: 'number', enum: [] } }, + required: ['status'], + }, message: 'invalid "status" field in the detailed output schema', }, { @@ -798,23 +1124,30 @@ describe('openAPIGenerator operation builders', () => { }, { name: 'a detailed status union contains a non-literal member', - output: { type: 'object', properties: { status: { anyOf: [{ const: 200 }, { type: 'number' }] } }, required: ['status'] }, + output: { + type: 'object', + properties: { status: { anyOf: [{ const: 200 }, { type: 'number' }] } }, + required: ['status'], + }, message: 'invalid "status" field in the detailed output schema', }, { name: 'a detailed status union contains a non-integer literal', - output: { type: 'object', properties: { status: { anyOf: [{ const: 200 }, { const: '201' }] } }, required: ['status'] }, + output: { + type: 'object', + properties: { status: { anyOf: [{ const: 200 }, { const: '201' }] } }, + required: ['status'], + }, message: 'invalid "status" field in the detailed output schema', }, ])('throws when $name', ({ output, message }) => { const { ctx, operation } = createContext() - expect(() => buildSuccessResponse( - ctx, - operation, - testDef({ outputs: [testSchema(output as any)] }), - { outputStructure: 'detailed' }, - )).toThrow(message) + expect(() => + buildSuccessResponse(ctx, operation, testDef({ outputs: [testSchema(output as any)] }), { + outputStructure: 'detailed', + }), + ).toThrow(message) }) }) @@ -833,13 +1166,23 @@ describe('openAPIGenerator operation builders', () => { errorStatusMap: { 'BAD_REQUEST': 400, 'BAD_REQUEST_2': 400, 'custom-timeout': 408 }, }) - buildErrorResponse(ctx, operation, testDef({ - errors: { - 'BAD_REQUEST': { data: testSchema({ type: 'object', properties: { field: { type: 'string' } }, required: ['field'] }) }, - 'BAD_REQUEST_2': { message: 'Second bad request' }, - 'custom-timeout': {}, - }, - })) + buildErrorResponse( + ctx, + operation, + testDef({ + errors: { + 'BAD_REQUEST': { + data: testSchema({ + type: 'object', + properties: { field: { type: 'string' } }, + required: ['field'], + }), + }, + 'BAD_REQUEST_2': { message: 'Second bad request' }, + 'custom-timeout': {}, + }, + }), + ) expect(operation.responses?.['400']).toEqual({ description: 'Second bad request', @@ -887,7 +1230,7 @@ describe('openAPIGenerator operation builders', () => { defined: { const: true }, code: { const: 'BAD_REQUEST_2' }, message: { type: 'string', default: undefined }, - data: { }, + data: {}, }, required: ['defined', 'code', 'message'], }) @@ -907,12 +1250,16 @@ describe('openAPIGenerator operation builders', () => { it('uses the common status map by default and falls back to 400 for unknown codes', () => { const { ctx, operation } = createContext() - buildErrorResponse(ctx, operation, testDef({ - errors: { - NOT_FOUND: {}, - CUSTOM_UNKNOWN_CODE: {}, - }, - })) + buildErrorResponse( + ctx, + operation, + testDef({ + errors: { + NOT_FOUND: {}, + CUSTOM_UNKNOWN_CODE: {}, + }, + }), + ) expect(Object.keys(operation.responses ?? {})).toEqual(['400', '404']) }) @@ -920,13 +1267,17 @@ describe('openAPIGenerator operation builders', () => { it('ignores inherited properties of the status map for prototype-named codes', () => { const { ctx, operation } = createContext() - buildErrorResponse(ctx, operation, testDef({ - errors: { - constructor: {}, - toString: {}, - ['__proto__']: {}, - } as ErrorMap, - })) + buildErrorResponse( + ctx, + operation, + testDef({ + errors: { + constructor: {}, + toString: {}, + ['__proto__']: {}, + } as ErrorMap, + }), + ) expect(Object.keys(operation.responses ?? {})).toEqual(['400']) }) @@ -936,7 +1287,9 @@ describe('openAPIGenerator operation builders', () => { buildErrorResponse(ctx, operation, testDef({ errors: { '---': {} } })) - expect((operation.responses?.['400'] as any).content['application/json'].schema.oneOf[0]).toEqual({ + expect( + (operation.responses?.['400'] as any).content['application/json'].schema.oneOf[0], + ).toEqual({ $ref: '#/components/schemas/Error', }) }) @@ -944,28 +1297,36 @@ describe('openAPIGenerator operation builders', () => { it('keeps the error component name unique among local data defs', () => { const { doc, ctx, operation } = createContext() - buildErrorResponse(ctx, operation, testDef({ - errors: { - CONFLICT: { - data: testSchema({ - type: 'object', - properties: { nested: { $ref: '#/$defs/Conflict' } }, - required: ['nested'], - $defs: { Conflict: { type: 'string' } }, - }), + buildErrorResponse( + ctx, + operation, + testDef({ + errors: { + CONFLICT: { + data: testSchema({ + type: 'object', + properties: { nested: { $ref: '#/$defs/Conflict' } }, + required: ['nested'], + $defs: { Conflict: { type: 'string' } }, + }), + }, }, - }, - })) + }), + ) - expect((operation.responses?.['409'] as any).content['application/json'].schema.oneOf[0]).toEqual({ + expect( + (operation.responses?.['409'] as any).content['application/json'].schema.oneOf[0], + ).toEqual({ $ref: '#/components/schemas/Conflict2', }) expect(doc.components?.schemas?.Conflict).toEqual({ type: 'string' }) - expect(doc.components?.schemas?.Conflict2).toEqual(expect.objectContaining({ - properties: expect.objectContaining({ - code: { const: 'CONFLICT' }, + expect(doc.components?.schemas?.Conflict2).toEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + code: { const: 'CONFLICT' }, + }), }), - })) + ) }) it('renders error factory items registered under their code', () => { @@ -973,14 +1334,22 @@ describe('openAPIGenerator operation builders', () => { const ForbiddenError = error('FORBIDDEN', { message: 'Access denied', - data: testSchema({ type: 'object', properties: { reason: { type: 'string' } }, required: ['reason'] }), + data: testSchema({ + type: 'object', + properties: { reason: { type: 'string' } }, + required: ['reason'], + }), }) - buildErrorResponse(ctx, operation, testDef({ - errors: { - [ForbiddenError.code]: ForbiddenError, - }, - })) + buildErrorResponse( + ctx, + operation, + testDef({ + errors: { + [ForbiddenError.code]: ForbiddenError, + }, + }), + ) expect(operation.responses?.['403']).toEqual({ description: 'Access denied', @@ -1003,7 +1372,11 @@ describe('openAPIGenerator operation builders', () => { defined: { const: true }, code: { const: 'FORBIDDEN' }, message: { type: 'string', default: undefined }, - data: { type: 'object', properties: { reason: { type: 'string' } }, required: ['reason'] }, + data: { + type: 'object', + properties: { reason: { type: 'string' } }, + required: ['reason'], + }, }, required: ['defined', 'code', 'message', 'data'], }) @@ -1020,22 +1393,30 @@ describe('openAPIGenerator operation builders', () => { const { doc, ctx, operation } = createContext({ errorResponseBodySchema }) - buildErrorResponse(ctx, operation, testDef({ - errors: { - BAD_REQUEST: { data: testSchema({ type: 'object' }) }, - NOT_FOUND: {}, - }, - })) + buildErrorResponse( + ctx, + operation, + testDef({ + errors: { + BAD_REQUEST: { data: testSchema({ type: 'object' }) }, + NOT_FOUND: {}, + }, + }), + ) expect(errorResponseBodySchema).toHaveBeenCalledTimes(2) - expect(errorResponseBodySchema).toHaveBeenCalledWith([ - { - code: 'BAD_REQUEST', - defaultMessage: undefined, - dataOptional: false, - dataJsonSchema: { type: 'object' }, - }, - ], 400, expect.any(Function)) + expect(errorResponseBodySchema).toHaveBeenCalledWith( + [ + { + code: 'BAD_REQUEST', + defaultMessage: undefined, + dataOptional: false, + dataJsonSchema: { type: 'object' }, + }, + ], + 400, + expect.any(Function), + ) expect((operation.responses?.['400'] as any).content['application/json'].schema).toEqual({ type: 'object', @@ -1045,23 +1426,37 @@ describe('openAPIGenerator operation builders', () => { expect.objectContaining({ oneOf: expect.any(Array) }), ) // the default schema of 400 was not used, so its error component is not registered - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['NotFound', 'UndefinedError']) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual([ + 'NotFound', + 'UndefinedError', + ]) }) - it.each([null, undefined])('documents an empty error body when the custom schema is %s', (schema) => { - const { ctx, operation } = createContext({ errorResponseBodySchema: () => schema }) + it.each([null, undefined])( + 'documents an empty error body when the custom schema is %s', + (schema) => { + const { ctx, operation } = createContext({ errorResponseBodySchema: () => schema }) - buildErrorResponse(ctx, operation, testDef({ errors: { NOT_FOUND: { message: 'Missing' } } })) + buildErrorResponse( + ctx, + operation, + testDef({ errors: { NOT_FOUND: { message: 'Missing' } } }), + ) - expect(operation.responses?.['404']).toEqual({ description: 'Missing' }) - }) + expect(operation.responses?.['404']).toEqual({ description: 'Missing' }) + }, + ) it('documents error bodies under the configured content type', () => { - const { ctx, operation } = createContext({ errorResponseContentType: 'application/problem+json' }) + const { ctx, operation } = createContext({ + errorResponseContentType: 'application/problem+json', + }) buildErrorResponse(ctx, operation, testDef({ errors: { NOT_FOUND: {} } })) - expect(Object.keys((operation.responses?.['404'] as any).content)).toEqual(['application/problem+json']) + expect(Object.keys((operation.responses?.['404'] as any).content)).toEqual([ + 'application/problem+json', + ]) }) }) }) diff --git a/packages/openapi/src/openapi-generator-operation.ts b/packages/openapi/src/openapi-generator-operation.ts index 0bbcdfb6f1..cdf7430c64 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -1,11 +1,10 @@ import type { AnyProcedureContract, AnySchema } from '@orpc/contract' -import type { JsonObjectSchemaEntry, JsonSchema, JsonSchemaConverterDirection } from '@orpc/json-schema' -import type { Value } from '@orpc/shared' -import type { OpenAPIMeta } from './meta' -import type { OpenAPIComponentRegistry } from './openapi-generator-components' -import type { OpenAPIV3_2 } from './types' -import type { getDynamicPathParams } from './utils' import { getAsyncIteratorObjectSchemasDetails } from '@orpc/contract' +import type { + JsonObjectSchemaEntry, + JsonSchema, + JsonSchemaConverterDirection, +} from '@orpc/json-schema' import { combineJsonObjectSchemaEntries, combineJsonSchemasWithComposition, @@ -20,18 +19,24 @@ import { resolveJsonSchemaRootLocalRef, } from '@orpc/json-schema' import { DEFAULT_ERROR_STATUS, DEFAULT_SUCCESS_STATUS } from '@orpc/server' +import type { Value } from '@orpc/shared' import { getOwn, stringifyJSON, value } from '@orpc/shared' + import { DEFAULT_OPENAPI_INPUT_STRUCTURE, DEFAULT_OPENAPI_METHOD, DEFAULT_OPENAPI_OUTPUT_STRUCTURE, DEFAULT_OPENAPI_SUCCESS_DESCRIPTION, -} from './constants' -import { isBodylessMethod } from './utils' +} from './constants.ts' +import type { OpenAPIMeta } from './meta.ts' +import type { OpenAPIComponentRegistry } from './openapi-generator-components.ts' +import type { OpenAPIV3_2 } from './types.ts' +import type { getDynamicPathParams } from './utils.ts' +import { isBodylessMethod } from './utils.ts' export type DynamicPathParam = NonNullable>[number] -export class OpenAPIGeneratorError extends TypeError { } +export class OpenAPIGeneratorError extends TypeError {} export interface OpenAPIErrorBodyDefinition { code: string @@ -45,16 +50,28 @@ export interface OpenAPIErrorBodyDefinition { */ export interface OpenAPIOperationContext { registry: OpenAPIComponentRegistry - convertSchemas: (schemas: AnySchema[] | undefined, direction: JsonSchemaConverterDirection) => [JsonSchema, boolean] + convertSchemas: ( + schemas: AnySchema[] | undefined, + direction: JsonSchemaConverterDirection, + ) => [JsonSchema, boolean] errorStatusMap: Record - errorResponseBodySchema: Value< - JsonSchema | null | undefined, - [definedErrors: OpenAPIErrorBodyDefinition[], status: number, defaultSchema: () => JsonSchema] - > | undefined + errorResponseBodySchema: + | Value< + JsonSchema | null | undefined, + [ + definedErrors: OpenAPIErrorBodyDefinition[], + status: number, + defaultSchema: () => JsonSchema, + ] + > + | undefined errorResponseContentType: string } -export function toOpenAPIPath(path: `/${string}`, dynamicPathParams: DynamicPathParam[] | undefined): `/${string}` { +export function toOpenAPIPath( + path: `/${string}`, + dynamicPathParams: DynamicPathParam[] | undefined, +): `/${string}` { if (!dynamicPathParams?.length) { return path } @@ -93,7 +110,7 @@ export function buildRequest( ): void { const method = meta?.method ?? DEFAULT_OPENAPI_METHOD const inputStructure = meta?.inputStructure ?? DEFAULT_OPENAPI_INPUT_STRUCTURE - const dynamicParams = dynamicPathParams?.map(v => v.parameterName) + const dynamicParams = dynamicPathParams?.map((v) => v.parameterName) if (inputStructure === 'compact') { const iteratorDetails = getAsyncIteratorObjectSchemasDetails(def.inputSchemas) @@ -121,9 +138,10 @@ export function buildRequest( return } - const parts = inputStructure === 'compact' - ? extractCompactRequestParts(schema, optional, method, dynamicParams) - : extractDetailedRequestParts(schema) + const parts = + inputStructure === 'compact' + ? extractCompactRequestParts(schema, optional, method, dynamicParams) + : extractDetailedRequestParts(schema) renderPathParameters(ctx, operation, dynamicParams, parts.paramsEntries, meta?.paramsStyles) renderQueryParameters(ctx, operation, parts.queryEntries, meta?.queryStyles) @@ -147,9 +165,9 @@ function extractCompactRequestParts( if (!entries && isBodylessMethod(method)) { throw new OpenAPIGeneratorError( - `method is ${method} but the input schema is not an object.\n` - + ` ${method} sends every input field as a query parameter, so the input must be an object schema.\n` - + ` Fix: make the input an object, or use a method with a request body (POST, PUT, PATCH, DELETE).`, + `method is ${method} but the input schema is not an object.\n` + + ` ${method} sends every input field as a query parameter, so the input must be an object schema.\n` + + ` Fix: make the input an object, or use a method with a request body (POST, PUT, PATCH, DELETE).`, ) } @@ -160,17 +178,22 @@ function extractCompactRequestParts( const bodySchema = !hasBody ? undefined : !dynamicParams?.length - ? schema - : restEntries - ? combineJsonObjectSchemaEntries(restEntries, extractJsonObjectSchemaAdditionalProperties(schema)) - : undefined + ? schema + : restEntries + ? combineJsonObjectSchemaEntries( + restEntries, + extractJsonObjectSchemaAdditionalProperties(schema), + ) + : undefined return { paramsEntries, queryEntries: isBodylessMethod(method) ? restEntries : undefined, headersEntries: undefined, bodySchema, - bodyOptional: !dynamicParams?.length ? optional : restEntries?.every(([,,optional]) => optional), + bodyOptional: !dynamicParams?.length + ? optional + : restEntries?.every(([, , optional]) => optional), } } @@ -179,8 +202,8 @@ function extractDetailedRequestParts(schema: JsonSchema): RequestParts { if (!entries) { throw new OpenAPIGeneratorError( - `inputStructure is "detailed" but the input schema is not an object.\n` - + ` Expected an object shaped like: { params?: object, query?: object, headers?: object, body?: unknown }`, + `inputStructure is "detailed" but the input schema is not an object.\n` + + ` Expected an object shaped like: { params?: object, query?: object, headers?: object, body?: unknown }`, ) } @@ -208,9 +231,9 @@ function renderPathParameters( if (!paramsEntries) { throw new OpenAPIGeneratorError( - `the route declares path params (${dynamicParams.map(p => `{${p}}`).join(', ')}) but there is no object schema to source them from.\n` - + ` compact mode: the input schema must be an object containing each param.\n` - + ` detailed mode: the input's "params" section must be an object containing each param.`, + `the route declares path params (${dynamicParams.map((p) => `{${p}}`).join(', ')}) but there is no object schema to source them from.\n` + + ` compact mode: the input schema must be an object containing each param.\n` + + ` detailed mode: the input's "params" section must be an object containing each param.`, ) } @@ -219,16 +242,16 @@ function renderPathParameters( if (!entry) { throw new OpenAPIGeneratorError( - `dynamic param "{${name}}" is missing from the input schema.\n` - + ` Route params: ${dynamicParams.map(p => `{${p}}`).join(', ')}\n` - + ` Schema keys: ${paramsEntries.map(([n]) => n).join(', ') || '(none)'}`, + `dynamic param "{${name}}" is missing from the input schema.\n` + + ` Route params: ${dynamicParams.map((p) => `{${p}}`).join(', ')}\n` + + ` Schema keys: ${paramsEntries.map(([n]) => n).join(', ') || '(none)'}`, ) } if (entry[2]) { throw new OpenAPIGeneratorError( - `dynamic param "${name}" is optional in the input schema.\n` - + ` OpenAPI requires path params to be required. Make "${name}" required.`, + `dynamic param "${name}" is optional in the input schema.\n` + + ` OpenAPI requires path params to be required. Make "${name}" required.`, ) } @@ -243,8 +266,7 @@ function renderPathParameters( if (style === 'comma-delimited-array' || style === 'comma-delimited-object') { parameter.style = 'simple' parameter.explode = false - } - else { + } else { const _expect: 'primitive' | undefined = style } @@ -275,23 +297,17 @@ function renderQueryParameters( if (style === 'comma-delimited-array' || style === 'comma-delimited-object') { parameter.explode = false - } - else if (style === 'pipe-delimited-array' || style === 'pipe-delimited-object') { + } else if (style === 'pipe-delimited-array' || style === 'pipe-delimited-object') { parameter.style = 'pipeDelimited' - } - else if (style === 'space-delimited-array' || style === 'space-delimited-object') { + } else if (style === 'space-delimited-array' || style === 'space-delimited-object') { parameter.style = 'spaceDelimited' - } - else if (style === 'json') { + } else if (style === 'json') { parameter.content = { 'application/json': { schema: parameter.schema }, } delete parameter.schema delete parameter.allowReserved - } - else if (style === undefined) { - const isPrimitive = (s: JsonSchema) => isJsonPrimitiveSchema(resolveJsonSchemaRootLocalRef(s)) - + } else if (style === undefined) { if (!flattenJsonUnionSchema(schema).every(isPrimitive)) { const arrayable = matchArrayableJsonSchema(schema) @@ -300,8 +316,7 @@ function renderQueryParameters( parameter.explode = true } } - } - else { + } else { const _expect: 'primitive' | 'array' = style } @@ -372,11 +387,17 @@ export function buildSuccessResponse( } if (parts.bodies.length) { - responseObject.content = toBodyContent(ctx, 'output', combineJsonSchemasWithComposition('anyOf', parts.bodies)) + responseObject.content = toBodyContent( + ctx, + 'output', + combineJsonSchemasWithComposition('anyOf', parts.bodies), + ) } if (parts.headers.length) { - const entries = extractJsonObjectSchemaEntries(combineJsonSchemasWithComposition('anyOf', parts.headers)) + const entries = extractJsonObjectSchemaEntries( + combineJsonSchemasWithComposition('anyOf', parts.headers), + ) entries?.forEach(([name, schema, optional]) => { responseObject.headers ??= {} responseObject.headers[name] = { @@ -405,27 +426,34 @@ function toResponseStatusKey(status: number): `${1 | 2 | 3 | 4 | 5}${string}` { function extractDetailedResponseParts( schema: JsonSchema, defaultStatus: number, -): Map { - const partsByStatus = new Map() +): Map { + const partsByStatus = new Map< + number, + { descriptions: string[]; bodies: JsonSchema[]; headers: JsonSchema[] } + >() for (const item of flattenJsonUnionSchema(schema)) { const entries = extractJsonObjectSchemaEntries(item) if (!entries) { throw new OpenAPIGeneratorError( - `outputStructure is "detailed" but the output schema (or one of its union members) is not an object.\n` - + ` Expected each member shaped like: { status?: number (< 400), headers?: object, body?: unknown }`, + `outputStructure is "detailed" but the output schema (or one of its union members) is not an object.\n` + + ` Expected each member shaped like: { status?: number (< 400), headers?: object, body?: unknown }`, ) } - const [, statusSchema, statusOptional = true] = entries.find(([name]) => name === 'status') ?? [] - const statuses = statusSchema === undefined ? new Map() : extractDetailedStatuses(statusSchema) + const [, statusSchema, statusOptional = true] = + entries.find(([name]) => name === 'status') ?? [] + const statuses = + statusSchema === undefined + ? new Map() + : extractDetailedStatuses(statusSchema) if (!statuses) { throw new OpenAPIGeneratorError( - `invalid "status" field in the detailed output schema.\n` - + ` Expected: literal integers below 400 (const, enum, or a union of them)\n` - + ` Received: ${stringifyJSON(statusSchema)}`, + `invalid "status" field in the detailed output schema.\n` + + ` Expected: literal integers below 400 (const, enum, or a union of them)\n` + + ` Received: ${stringifyJSON(statusSchema)}`, ) } @@ -508,7 +536,10 @@ export function buildErrorResponse( } const status = getOwn(ctx.errorStatusMap, code) ?? DEFAULT_ERROR_STATUS - const [dataJsonSchema, dataOptional] = ctx.convertSchemas(config.data !== undefined ? [config.data] : undefined, 'output') + const [dataJsonSchema, dataOptional] = ctx.convertSchemas( + config.data !== undefined ? [config.data] : undefined, + 'output', + ) let definitions = definitionsByStatus.get(status) if (!definitions) { @@ -536,29 +567,36 @@ export function buildErrorResponse( }) for (const [status, definitions] of definitionsByStatus.entries()) { - const descriptions = definitions.map(({ defaultMessage }) => defaultMessage).filter(m => m !== undefined) - const definedErrors = definitions.map(def => ({ ...def, dataJsonSchema: ctx.registry.hoistDefs(def.dataJsonSchema, 'output') })) + const descriptions = definitions + .map(({ defaultMessage }) => defaultMessage) + .filter((m) => m !== undefined) + const definedErrors = definitions.map((def) => ({ + ...def, + dataJsonSchema: ctx.registry.hoistDefs(def.dataJsonSchema, 'output'), + })) // lazy, so per-code error components are only registered when the default schema is actually used - const defaultSchema = (): JsonSchema => combineJsonSchemasWithComposition('oneOf', [ - ...definedErrors.map(({ code, dataJsonSchema, dataOptional }) => { - return ctx.registry.register(toErrorComponentName(code), { - title: code, - ...combineJsonObjectSchemaEntries([ - ['defined', { const: true }, false], - ['code', { const: code }, false], - // avoid using the defaultMessage here to improve component reusability - ['message', { type: 'string' }, false], - ['data', dataJsonSchema, dataOptional], - ]), - }) - }), - undefinedErrorSchema, - ]) - - const responseSchema = ctx.errorResponseBodySchema === undefined - ? defaultSchema() - : value(ctx.errorResponseBodySchema, definedErrors, status, defaultSchema) + const defaultSchema = (): JsonSchema => + combineJsonSchemasWithComposition('oneOf', [ + ...definedErrors.map(({ code, dataJsonSchema, dataOptional }) => { + return ctx.registry.register(toErrorComponentName(code), { + title: code, + ...combineJsonObjectSchemaEntries([ + ['defined', { const: true }, false], + ['code', { const: code }, false], + // avoid using the defaultMessage here to improve component reusability + ['message', { type: 'string' }, false], + ['data', dataJsonSchema, dataOptional], + ]), + }) + }), + undefinedErrorSchema, + ]) + + const responseSchema = + ctx.errorResponseBodySchema === undefined + ? defaultSchema() + : value(ctx.errorResponseBodySchema, definedErrors, status, defaultSchema) const responseObject: OpenAPIV3_2.ResponseObject = { description: descriptions.length ? descriptions.join(', ') : status.toString(), @@ -586,7 +624,9 @@ function toErrorComponentName(code: string): string { .filter(Boolean) .map((part) => { const rest = part.slice(1) - return part.charAt(0).toUpperCase() + (rest === rest.toUpperCase() ? rest.toLowerCase() : rest) + return ( + part.charAt(0).toUpperCase() + (rest === rest.toUpperCase() ? rest.toLowerCase() : rest) + ) }) .join('') @@ -631,7 +671,11 @@ function toAsyncIteratorObjectContent( } } -function toBodyContent(ctx: OpenAPIOperationContext, direction: JsonSchemaConverterDirection, schema: JsonSchema): Record { +function toBodyContent( + ctx: OpenAPIOperationContext, + direction: JsonSchemaConverterDirection, + schema: JsonSchema, +): Record { const fileSchemasByMediaType = new Map() const rest = flattenJsonUnionSchema(schema).filter((s) => { @@ -644,8 +688,7 @@ function toBodyContent(ctx: OpenAPIOperationContext, direction: JsonSchemaConver const schemas = fileSchemasByMediaType.get(contentMediaType) if (schemas) { schemas.push(s) - } - else { + } else { fileSchemasByMediaType.set(contentMediaType, [s]) } @@ -655,7 +698,9 @@ function toBodyContent(ctx: OpenAPIOperationContext, direction: JsonSchemaConver const content: Record = {} if (rest.length > 0) { - const restSchema = fileSchemasByMediaType.size ? combineJsonSchemasWithComposition('anyOf', rest) : schema + const restSchema = fileSchemasByMediaType.size + ? combineJsonSchemasWithComposition('anyOf', rest) + : schema const hasNestedFiles = containsJsonFileSchema(restSchema) const contentType = hasNestedFiles ? 'multipart/form-data' : 'application/json' @@ -663,15 +708,25 @@ function toBodyContent(ctx: OpenAPIOperationContext, direction: JsonSchemaConver fileSchemasByMediaType.delete(contentType) content[contentType] = { - schema: ctx.registry.toOpenAPISchema(combineJsonSchemasWithComposition('anyOf', [restSchema, ...fileSchemas ?? []]), direction), + schema: ctx.registry.toOpenAPISchema( + combineJsonSchemasWithComposition('anyOf', [restSchema, ...(fileSchemas ?? [])]), + direction, + ), } } for (const [contentType, schemas] of fileSchemasByMediaType.entries()) { content[contentType] = { - schema: ctx.registry.toOpenAPISchema(combineJsonSchemasWithComposition('anyOf', schemas), direction), + schema: ctx.registry.toOpenAPISchema( + combineJsonSchemasWithComposition('anyOf', schemas), + direction, + ), } } return content } + +function isPrimitive(schema: JsonSchema): boolean { + return isJsonPrimitiveSchema(resolveJsonSchemaRootLocalRef(schema)) +} diff --git a/packages/openapi/src/openapi-generator.test-d.ts b/packages/openapi/src/openapi-generator.test-d.ts index 8792f75c4c..3425c2e581 100644 --- a/packages/openapi/src/openapi-generator.test-d.ts +++ b/packages/openapi/src/openapi-generator.test-d.ts @@ -1,5 +1,11 @@ -import type { OpenAPIDocument, OpenAPIV3_0, OpenAPIV3_1, OpenAPIV3_2, OpenAPIVersion } from './types' -import { OpenAPIGenerator } from './openapi-generator' +import { OpenAPIGenerator } from './openapi-generator.ts' +import type { + OpenAPIDocument, + OpenAPIV3_0, + OpenAPIV3_1, + OpenAPIV3_2, + OpenAPIVersion, +} from './types.ts' describe('openAPIGenerator.generate', () => { const generator = new OpenAPIGenerator() @@ -7,27 +13,43 @@ describe('openAPIGenerator.generate', () => { it('returns the document type of the requested version', () => { expectTypeOf(generator.generate({})).resolves.toEqualTypeOf() expectTypeOf(generator.generate({}, {})).resolves.toEqualTypeOf() - expectTypeOf(generator.generate({}, { version: '3.2.0' })).resolves.toEqualTypeOf() - expectTypeOf(generator.generate({}, { version: '3.2.7' })).resolves.toEqualTypeOf() - expectTypeOf(generator.generate({}, { version: '3.1.2' })).resolves.toEqualTypeOf() - expectTypeOf(generator.generate({}, { version: '3.1.0' })).resolves.toEqualTypeOf() - expectTypeOf(generator.generate({}, { version: '3.0.4' })).resolves.toEqualTypeOf() - expectTypeOf(generator.generate({}, { version: '3.0.0' })).resolves.toEqualTypeOf() + expectTypeOf( + generator.generate({}, { version: '3.2.0' }), + ).resolves.toEqualTypeOf() + expectTypeOf( + generator.generate({}, { version: '3.2.7' }), + ).resolves.toEqualTypeOf() + expectTypeOf( + generator.generate({}, { version: '3.1.2' }), + ).resolves.toEqualTypeOf() + expectTypeOf( + generator.generate({}, { version: '3.1.0' }), + ).resolves.toEqualTypeOf() + expectTypeOf( + generator.generate({}, { version: '3.0.4' }), + ).resolves.toEqualTypeOf() + expectTypeOf( + generator.generate({}, { version: '3.0.0' }), + ).resolves.toEqualTypeOf() }) it('widens to every supported document when the version is not a literal', () => { const version = '3.1.2' as OpenAPIVersion - expectTypeOf(generator.generate({}, { version })).resolves.toEqualTypeOf>() - expectTypeOf(generator.generate({}, { version: undefined })).resolves.toEqualTypeOf>() + expectTypeOf(generator.generate({}, { version })).resolves.toEqualTypeOf< + OpenAPIDocument + >() + expectTypeOf(generator.generate({}, { version: undefined })).resolves.toEqualTypeOf< + OpenAPIDocument + >() }) it('rejects unsupported versions and the openapi field in base', () => { // @ts-expect-error unsupported version - generator.generate({}, { version: '4.0.0' }) + void generator.generate({}, { version: '4.0.0' }) // @ts-expect-error the patch segment is required - generator.generate({}, { version: '3.2' }) + void generator.generate({}, { version: '3.2' }) // @ts-expect-error the openapi field is derived from version - generator.generate({}, { base: { openapi: '3.2.0' } }) + void generator.generate({}, { base: { openapi: '3.2.0' } }) }) }) diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index 9ce9847ef6..127df85fbd 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -1,9 +1,14 @@ import { oc } from '@orpc/contract' import * as arktype from 'arktype' -import z from 'zod' -import { testSchema, testSchemaConverter, zodJsonSchemaConverter } from '../tests/__shared__/schema' -import { openapi } from './meta' -import { OpenAPIGenerator, OpenAPIGeneratorError } from './openapi-generator' +import * as z from 'zod' + +import { + testSchema, + testSchemaConverter, + zodJsonSchemaConverter, +} from '../tests/__shared__/schema.ts' +import { openapi } from './meta.ts' +import { OpenAPIGenerator, OpenAPIGeneratorError } from './openapi-generator.ts' describe('openAPIGenerator basic & options', () => { const generator = new OpenAPIGenerator({ converters: [zodJsonSchemaConverter] }) @@ -27,58 +32,66 @@ describe('openAPIGenerator basic & options', () => { }) expect(doc.openapi).toBe('3.2.0') - expect(doc.paths?.['/search']?.query).toEqual(expect.objectContaining({ - requestBody: { - required: true, - content: { - 'application/json': { - schema: expect.objectContaining({ - properties: { term: { type: 'string' } }, - required: ['term'], - }), - }, - }, - }, - responses: { - 200: expect.objectContaining({ + expect(doc.paths?.['/search']?.query).toEqual( + expect.objectContaining({ + requestBody: { + required: true, content: { 'application/json': { schema: expect.objectContaining({ - properties: { result: { type: 'string' } }, - required: ['result'], + properties: { term: { type: 'string' } }, + required: ['term'], }), }, }, - }), - }, - })) + }, + responses: { + 200: expect.objectContaining({ + content: { + 'application/json': { + schema: expect.objectContaining({ + properties: { result: { type: 'string' } }, + required: ['result'], + }), + }, + }, + }), + }, + }), + ) }) it('rejects QUERY operations when an older OpenAPI version is requested', async () => { - await expect(generator.generate({ - search: oc.meta(openapi({ method: 'QUERY', path: '/search' })), - }, { version: '3.1.2' })).rejects.toThrow( - 'QUERY operations require OpenAPI 3.2, but version "3.1.2" was requested.', - ) + await expect( + generator.generate( + { + search: oc.meta(openapi({ method: 'QUERY', path: '/search' })), + }, + { version: '3.1.2' }, + ), + ).rejects.toThrow('QUERY operations require OpenAPI 3.2, but version "3.1.2" was requested.') }) it('merges the provided base document and serialize the result', async () => { const serializer = { - serializeJson: vi.fn(document => ({ json: document })), + serializeJson: vi.fn((document) => ({ json: document })), deserializeJson: vi.fn(), } const generator = new OpenAPIGenerator({ serializer, converters: [zodJsonSchemaConverter] }) - const doc = await generator.generate({}, { - base: { - info: { - title: 'Planet API', - version: '1.2.3', + const doc = await generator.generate( + {}, + { + base: { + info: { + title: 'Planet API', + version: '1.2.3', + }, + servers: [{ url: 'https://api.example.com' }], }, - servers: [{ url: 'https://api.example.com' }], }, - }) + ) expect(serializer.serializeJson).toHaveBeenCalledWith({ openapi: '3.2.0', @@ -110,9 +123,12 @@ describe('openAPIGenerator basic & options', () => { } const snapshot = structuredClone(base) - const doc = await generator.generate({ - planet: oc.input(z.object({ planet: z.object({ id: z.string() }).meta({ id: 'Planet' }) })), - }, { base }) + const doc = await generator.generate( + { + planet: oc.input(z.object({ planet: z.object({ id: z.string() }).meta({ id: 'Planet' }) })), + }, + { base }, + ) expect(doc.components?.schemas).toEqual({ Existing: { type: 'string' }, @@ -125,16 +141,21 @@ describe('openAPIGenerator basic & options', () => { 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 filter = vi.fn( + (procedure, path) => procedure !== privateProcedure && path.join('.') !== 'admin.private', + ) - const doc = await generator.generate({ - public: publicProcedure, - admin: { - private: privateProcedure, + const doc = await generator.generate( + { + public: publicProcedure, + admin: { + private: privateProcedure, + }, }, - }, { - filter, - }) + { + filter, + }, + ) expect(filter).toHaveBeenCalledTimes(2) expect(filter).toHaveBeenNthCalledWith(1, publicProcedure, ['public']) @@ -184,84 +205,97 @@ describe('openAPIGenerator basic & options', () => { const doc = await generator.generate({ createPlanet: oc .meta(openapi({ method: 'POST', path: '/planets' })) - .input(testSchema({ - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - })) - .output(testSchema({ - type: 'object', - properties: { id: { type: 'string' } }, - required: ['id'], - })), - }) - - expect(doc.paths?.['/planets']?.post).toEqual(expect.objectContaining({ - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - }, - }, - }, - }, - responses: { - 200: { - description: 'OK', + .input( + testSchema({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }), + ) + .output( + testSchema({ + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }), + ), + }) + + expect(doc.paths?.['/planets']?.post).toEqual( + expect.objectContaining({ + requestBody: { + required: true, content: { 'application/json': { schema: { type: 'object', - properties: { id: { type: 'string' } }, - required: ['id'], + properties: { name: { type: 'string' } }, + required: ['name'], }, }, }, }, - }, - })) + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + }, + }, + }, + }, + }), + ) }) it('converts inputs with the "input" direction and outputs with the "output" direction', async () => { const generator = new OpenAPIGenerator({ converters: [testSchemaConverter] }) - const directionalSchema = () => testSchema( - { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] }, - { output: { type: 'object', properties: { parsed: { type: 'number' } }, required: ['parsed'] } }, - ) + const directionalSchema = () => + testSchema( + { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] }, + { + output: { + type: 'object', + properties: { parsed: { type: 'number' } }, + required: ['parsed'], + }, + }, + ) const doc = await generator.generate({ - transform: oc - .input(directionalSchema()) - .output(directionalSchema()), + transform: oc.input(directionalSchema()).output(directionalSchema()), }) - expect(doc.paths?.['/transform']?.post).toEqual(expect.objectContaining({ - requestBody: expect.objectContaining({ - content: { - 'application/json': { - schema: expect.objectContaining({ - properties: { raw: { type: 'string' } }, - }), - }, - }, - }), - responses: { - 200: expect.objectContaining({ + expect(doc.paths?.['/transform']?.post).toEqual( + expect.objectContaining({ + requestBody: expect.objectContaining({ content: { 'application/json': { schema: expect.objectContaining({ - properties: { parsed: { type: 'number' } }, + properties: { raw: { type: 'string' } }, }), }, }, }), - }, - })) + responses: { + 200: expect.objectContaining({ + content: { + 'application/json': { + schema: expect.objectContaining({ + properties: { parsed: { type: 'number' } }, + }), + }, + }, + }), + }, + }), + ) }) it('derives the path and operationId from router segments', async () => { @@ -283,16 +317,18 @@ describe('openAPIGenerator basic & options', () => { it('applies route metadata: method, prefix, path, operationId, tags, summary, description, deprecated', 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, - })) + .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, + }), + ) .input(z.object({ id: z.string() })), }) @@ -303,9 +339,7 @@ describe('openAPIGenerator basic & options', () => { summary: 'Get a planet', description: 'Returns a single planet.', deprecated: true, - parameters: [ - expect.objectContaining({ name: 'id', in: 'path', required: true }), - ], + parameters: [expect.objectContaining({ name: 'id', in: 'path', required: true })], }), }) }) @@ -328,10 +362,12 @@ describe('openAPIGenerator basic & options', () => { const doc = await generator.generate({ // this procedure would normally throw: GET with non-object input schema getPlanet: oc - .meta(openapi({ - method: 'GET', - spec: { operationId: 'custom.getPlanet' }, - })) + .meta( + openapi({ + method: 'GET', + spec: { operationId: 'custom.getPlanet' }, + }), + ) .input(z.string()), }) @@ -342,10 +378,12 @@ describe('openAPIGenerator basic & options', () => { it('extends the generated operation with an openapi.spec function', async () => { const doc = await generator.generate({ - getPlanet: oc.meta(openapi({ - method: 'GET', - spec: current => ({ ...current, security: [{ bearerAuth: [] }] }), - })), + getPlanet: oc.meta( + openapi({ + method: 'GET', + spec: (current) => ({ ...current, security: [{ bearerAuth: [] }] }), + }), + ), }) expect(doc.paths?.['/getPlanet']).toEqual({ @@ -365,35 +403,37 @@ describe('openAPIGenerator basic & options', () => { .output(z.looseObject({ slug: z.string() })), }) - expect(doc.paths?.['/planet']?.post).toEqual(expect.objectContaining({ - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - allOf: [ - expect.objectContaining({ required: ['name'] }), - expect.objectContaining({ required: ['note'] }), - ], - }, - }, - }, - }, - responses: { - 200: expect.objectContaining({ + expect(doc.paths?.['/planet']?.post).toEqual( + expect.objectContaining({ + requestBody: { + required: true, content: { 'application/json': { schema: { allOf: [ - expect.objectContaining({ required: ['id'] }), - expect.objectContaining({ required: ['slug'] }), + expect.objectContaining({ required: ['name'] }), + expect.objectContaining({ required: ['note'] }), ], }, }, }, - }), - }, - })) + }, + responses: { + 200: expect.objectContaining({ + content: { + 'application/json': { + schema: { + allOf: [ + expect.objectContaining({ required: ['id'] }), + expect.objectContaining({ required: ['slug'] }), + ], + }, + }, + }, + }), + }, + }), + ) }) it('keeps merged request bodies optional when every input schema is optional', async () => { @@ -415,29 +455,50 @@ describe('openAPIGenerator basic & options', () => { .input(z.object({ status: Status, statuses: z.union([Status, z.array(Status)]) })), download: oc .meta(openapi({ method: 'GET', path: '/download', outputStructure: 'detailed' })) - .output(z.union([ - z.object({ status: z.literal(200), body: z.file().meta({ id: 'Download' }) }), - z.object({ status: z.literal(201), body: z.object({ id: z.string() }) }), - ])), + .output( + z.union([ + z.object({ status: z.literal(200), body: z.file().meta({ id: 'Download' }) }), + z.object({ status: z.literal(201), body: z.object({ id: z.string() }) }), + ]), + ), }) expect(doc.paths?.['/list']?.get?.parameters).toEqual([ - { in: 'query', name: 'status', required: true, allowEmptyValue: true, allowReserved: true, schema: { $ref: '#/components/schemas/Status' } }, + { + in: 'query', + name: 'status', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { $ref: '#/components/schemas/Status' }, + }, { in: 'query', name: 'statuses', required: true, allowEmptyValue: true, allowReserved: true, - schema: { anyOf: [{ $ref: '#/components/schemas/Status' }, { type: 'array', items: { $ref: '#/components/schemas/Status' } }] }, + schema: { + anyOf: [ + { $ref: '#/components/schemas/Status' }, + { type: 'array', items: { $ref: '#/components/schemas/Status' } }, + ], + }, }, ]) expect(doc.paths?.['/download']?.get?.responses).toEqual({ - 200: { description: 'OK', content: { '*/*': { schema: { $ref: '#/components/schemas/Download' } } } }, + 200: { + description: 'OK', + content: { '*/*': { schema: { $ref: '#/components/schemas/Download' } } }, + }, 201: { description: 'OK', - content: { 'application/json': { schema: expect.objectContaining({ properties: { id: { type: 'string' } } }) } }, + content: { + 'application/json': { + schema: expect.objectContaining({ properties: { id: { type: 'string' } } }), + }, + }, }, }) }) @@ -456,57 +517,75 @@ describe('openAPIGenerator basic & options', () => { const generator = new OpenAPIGenerator({ converters: [testSchemaConverter] }) const doc = await generator.generate({ - ping: oc.input(testSchema({ - $schema: 'https://json-schema.org/draft/2020-12/schema', - type: 'object', - } as any)), + ping: oc.input( + testSchema({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + } as any), + ), }) - expect((doc.paths?.['/ping']?.post?.requestBody as any).content['application/json'].schema).toEqual({ + expect( + (doc.paths?.['/ping']?.post?.requestBody as any).content['application/json'].schema, + ).toEqual({ type: 'object', }) }) it('aggregates all generator errors into a single OpenAPIGeneratorError', async () => { - const error = await generator.generate({ - first: oc.meta(openapi({ path: '/planets/{id}' })), - nested: { - second: oc.meta(openapi({ method: 'GET' })).input(z.string()), - }, - }).then( - () => { throw new Error('expected generate to reject') }, - e => e, - ) + const error = await generator + .generate({ + first: oc.meta(openapi({ path: '/planets/{id}' })), + nested: { + second: oc.meta(openapi({ method: 'GET' })).input(z.string()), + }, + }) + .then( + () => { + throw new Error('expected generate to reject') + }, + (e) => e, + ) expect(error).toBeInstanceOf(OpenAPIGeneratorError) expect(error.message).toContain('Failed to generate the OpenAPI document (2 errors)') expect(error.message).toContain('Procedure at first:') expect(error.message).toContain('Procedure at nested.second:') - const single = await generator.generate({ - only: oc.meta(openapi({ method: 'GET' })).input(z.string()), - }).then( - () => { throw new Error('expected generate to reject') }, - e => e, - ) + const single = await generator + .generate({ + only: oc.meta(openapi({ method: 'GET' })).input(z.string()), + }) + .then( + () => { + throw new Error('expected generate to reject') + }, + (e) => e, + ) expect(single.message).toContain('Failed to generate the OpenAPI document (1 error)') - const root = await generator.generate( - oc.meta(openapi({ method: 'GET' })).input(z.string()), - ).then( - () => { throw new Error('expected generate to reject') }, - e => e, - ) + const root = await generator + .generate(oc.meta(openapi({ method: 'GET' })).input(z.string())) + .then( + () => { + throw new Error('expected generate to reject') + }, + (e) => e, + ) expect(root.message).toContain('Procedure at (root):') - const emptyKey = await generator.generate({ - '': oc.meta(openapi({ method: 'GET' })).input(z.string()), - }).then( - () => { throw new Error('expected generate to reject') }, - e => e, - ) + const emptyKey = await generator + .generate({ + '': oc.meta(openapi({ method: 'GET' })).input(z.string()), + }) + .then( + () => { + throw new Error('expected generate to reject') + }, + (e) => e, + ) expect(emptyKey.message).toContain('Procedure at : ') expect(emptyKey.message).not.toContain('(root)') @@ -540,12 +619,15 @@ describe('openAPIGenerator basic & options', () => { }) it('does not report collisions for filtered-out procedures', async () => { - const doc = await generator.generate({ - first: oc.meta(openapi({ method: 'GET', path: '/users' })), - second: oc.meta(openapi({ method: 'GET', path: '/users' })), - }, { - filter: (_, path) => path[0] !== 'second', - }) + const doc = await generator.generate( + { + first: oc.meta(openapi({ method: 'GET', path: '/users' })), + second: oc.meta(openapi({ method: 'GET', path: '/users' })), + }, + { + filter: (_, path) => path[0] !== 'second', + }, + ) expect(doc.paths?.['/users']).toEqual({ get: expect.objectContaining({ operationId: 'first' }), @@ -553,12 +635,18 @@ describe('openAPIGenerator basic & options', () => { }) it('reports duplicate operationIds', async () => { - await expect(generator.generate({ - first: oc.meta(openapi({ method: 'GET', path: '/a', operationId: 'same' })), - nested: { - second: oc.meta(openapi({ method: 'GET', path: '/b', spec: op => ({ ...op, operationId: 'same' }) })), - }, - })).rejects.toThrow('Procedure at nested.second: operationId "same" is already used by the procedure at first') + await expect( + generator.generate({ + first: oc.meta(openapi({ method: 'GET', path: '/a', operationId: 'same' })), + nested: { + second: oc.meta( + openapi({ method: 'GET', path: '/b', spec: (op) => ({ ...op, operationId: 'same' }) }), + ), + }, + }), + ).rejects.toThrow( + 'Procedure at nested.second: operationId "same" is already used by the procedure at first', + ) }) it('documents the success status for an optional detailed status', async () => { @@ -573,18 +661,26 @@ describe('openAPIGenerator basic & options', () => { it('rethrows non-generator errors immediately without wrapping', async () => { const generator = new OpenAPIGenerator({ - converters: [{ - condition: () => true, - convert: () => { throw new Error('converter exploded') }, - }], + converters: [ + { + condition: () => true, + convert: () => { + throw new Error('converter exploded') + }, + }, + ], }) - const error = await generator.generate({ - ping: oc.input(z.string()), - }).then( - () => { throw new Error('expected generate to reject') }, - e => e, - ) + const error = await generator + .generate({ + ping: oc.input(z.string()), + }) + .then( + () => { + throw new Error('expected generate to reject') + }, + (e) => e, + ) expect(error).not.toBeInstanceOf(OpenAPIGeneratorError) expect(error.message).toBe('converter exploded') @@ -597,16 +693,16 @@ describe('openAPIGenerator version', () => { const router = { planet: oc .meta(openapi({ method: 'GET', path: '/planets/{id}' })) - .input(testSchema({ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] })) + .input( + testSchema({ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }), + ) .output(testSchema({ type: 'object', properties: { name: { type: ['string', 'null'] } } })), } const planetPath = { get: { operationId: 'planet', - parameters: [ - { in: 'path', name: 'id', required: true, schema: { type: 'string' } }, - ], + parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'OK', @@ -649,7 +745,10 @@ describe('openAPIGenerator version', () => { description: 'OK', content: { 'application/json': { - schema: { type: 'object', properties: { name: { type: 'string', nullable: true } } }, + schema: { + type: 'object', + properties: { name: { type: 'string', nullable: true } }, + }, }, }, }, @@ -660,43 +759,58 @@ describe('openAPIGenerator version', () => { }) }) - it.each(['3.2.0', '3.1.0', '3.0.3'] as const)('preserves error titles in OpenAPI %s', async (version) => { - const doc = await generator.generate({ - create: oc.meta(openapi({ method: 'POST', path: '/penguins' })).errors({ - PENGUIN_NAME_BANNED: {}, - PENGUIN_COLONY_FULL: {}, - }), - }, { - version, - errorStatusMap: { PENGUIN_NAME_BANNED: 400, PENGUIN_COLONY_FULL: 400 }, - }) - - const response = doc.paths?.['/penguins']?.post?.responses?.['400'] - expect(response).toMatchObject({ - content: { - 'application/json': { - schema: { - oneOf: [ - { $ref: '#/components/schemas/PenguinNameBanned' }, - { $ref: '#/components/schemas/PenguinColonyFull' }, - { $ref: '#/components/schemas/UndefinedError' }, - ], + it.each(['3.2.0', '3.1.0', '3.0.3'] as const)( + 'preserves error titles in OpenAPI %s', + async (version) => { + const doc = await generator.generate( + { + create: oc.meta(openapi({ method: 'POST', path: '/penguins' })).errors({ + PENGUIN_NAME_BANNED: {}, + PENGUIN_COLONY_FULL: {}, + }), + }, + { + version, + errorStatusMap: { PENGUIN_NAME_BANNED: 400, PENGUIN_COLONY_FULL: 400 }, + }, + ) + + const response = doc.paths?.['/penguins']?.post?.responses?.['400'] + expect(response).toMatchObject({ + content: { + 'application/json': { + schema: { + oneOf: [ + { $ref: '#/components/schemas/PenguinNameBanned' }, + { $ref: '#/components/schemas/PenguinColonyFull' }, + { $ref: '#/components/schemas/UndefinedError' }, + ], + }, }, }, - }, - }) - expect(doc.components?.schemas).toMatchObject({ - PenguinNameBanned: { title: 'PENGUIN_NAME_BANNED' }, - PenguinColonyFull: { title: 'PENGUIN_COLONY_FULL' }, - UndefinedError: { title: 'UndefinedError' }, - }) - }) + }) + expect(doc.components?.schemas).toMatchObject({ + PenguinNameBanned: { title: 'PENGUIN_NAME_BANNED' }, + PenguinColonyFull: { title: 'PENGUIN_COLONY_FULL' }, + UndefinedError: { title: 'UndefinedError' }, + }) + }, + ) it('serializes non-JSON values after downgrading', async () => { - const doc = await generator.generate({}, { - version: '3.0.4', - base: { info: { 'title': 'Planet API', 'version': '1.0.0', 'x-generated-at': new Date('2020-01-01T00:00:00.000Z') } }, - }) + const doc = await generator.generate( + {}, + { + version: '3.0.4', + base: { + info: { + 'title': 'Planet API', + 'version': '1.0.0', + 'x-generated-at': new Date('2020-01-01T00:00:00.000Z'), + }, + }, + }, + ) expect(doc.info['x-generated-at']).toBe('2020-01-01T00:00:00.000Z') }) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index 253613d5e8..3c1e162300 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -1,30 +1,43 @@ -import type { AnyProcedureContract, AnySchema, RouterContract } from '@orpc/contract' -import type { JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' -import type { AnyProcedure, AnyRouter } from '@orpc/server' -import type { Public, Value } from '@orpc/shared' -import type { OpenAPIMeta } from './meta' -import type { OpenAPIErrorBodyDefinition, OpenAPIOperationContext } from './openapi-generator-operation' -import type { OpenAPIDocument, OpenAPIV3_2, OpenAPIVersion } from './types' import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' import { COMMON_ERROR_STATUS_MAP } from '@orpc/client' -import { combineJsonSchemasWithComposition, DelegatingJsonSchemaConverter, hoistRecursiveRefToDef, StandardJsonSchemaConverter } from '@orpc/json-schema' +import type { AnyProcedureContract, AnySchema, RouterContract } from '@orpc/contract' +import type { + JsonSchema, + JsonSchemaConverter, + JsonSchemaConverterDirection, +} from '@orpc/json-schema' +import { + combineJsonSchemasWithComposition, + DelegatingJsonSchemaConverter, + hoistRecursiveRefToDef, + StandardJsonSchemaConverter, +} from '@orpc/json-schema' +import type { AnyProcedure, AnyRouter } from '@orpc/server' import { walkProcedureContractsAsync } from '@orpc/server' +import type { Public, Value } from '@orpc/shared' import { clone, mergeHttpPath, pathToHttpPath, toArray, value } from '@orpc/shared' -import { DEFAULT_OPENAPI_METHOD } from './constants' -import { getOpenAPIMeta } from './meta' -import { OpenAPIComponentRegistry } from './openapi-generator-components' + +import { DEFAULT_OPENAPI_METHOD } from './constants.ts' +import type { OpenAPIMeta } from './meta.ts' +import { getOpenAPIMeta } from './meta.ts' +import { OpenAPIComponentRegistry } from './openapi-generator-components.ts' +import type { + OpenAPIErrorBodyDefinition, + OpenAPIOperationContext, +} from './openapi-generator-operation.ts' import { buildErrorResponse, buildRequest, buildSuccessResponse, OpenAPIGeneratorError, toOpenAPIPath, -} from './openapi-generator-operation' -import { OpenAPIJsonSerializer } from './openapi-json-serializer' -import { getDynamicPathParams } from './utils' +} from './openapi-generator-operation.ts' +import { OpenAPIJsonSerializer } from './openapi-json-serializer.ts' +import type { OpenAPIDocument, OpenAPIV3_2, OpenAPIVersion } from './types.ts' +import { getDynamicPathParams } from './utils.ts' -export { OpenAPIGeneratorError } from './openapi-generator-operation' -export type { OpenAPIErrorBodyDefinition } from './openapi-generator-operation' +export { OpenAPIGeneratorError } from './openapi-generator-operation.ts' +export type { OpenAPIErrorBodyDefinition } from './openapi-generator-operation.ts' export interface OpenAPIGeneratorOptions { converters?: JsonSchemaConverter[] | undefined @@ -69,7 +82,9 @@ export interface OpenAPIGeneratorGenerateOptions | undefined + filter?: + | Value + | undefined /** * Define a custom JSON schema for the error response body when using @@ -80,10 +95,16 @@ export interface OpenAPIGeneratorGenerateOptions JsonSchema] - > | undefined + errorResponseBodySchema?: + | Value< + JsonSchema | null | undefined, + [ + definedErrors: OpenAPIErrorBodyDefinition[], + status: number, + defaultSchema: () => JsonSchema, + ] + > + | undefined /** * Media type of error responses in the generated document, e.g. `application/problem+json`. @@ -155,7 +176,9 @@ export class OpenAPIGenerator { const def = contract['~orpc'] const meta = getOpenAPIMeta(contract) - const method = (meta?.method ?? DEFAULT_OPENAPI_METHOD).toLowerCase() as Lowercase> + const method = (meta?.method ?? DEFAULT_OPENAPI_METHOD).toLowerCase() as Lowercase< + NonNullable + > if (method === 'query' && !version.startsWith('3.2.')) { throw new OpenAPIGeneratorError( @@ -172,14 +195,13 @@ export class OpenAPIGenerator { if (meta?.spec !== undefined && typeof meta.spec !== 'function') { operation = meta.spec - } - else { + } else { operation = { operationId: meta?.operationId ?? path.join('.'), summary: meta?.summary, description: meta?.description, deprecated: meta?.deprecated, - tags: meta?.tags?.map(tag => tag), + tags: meta?.tags?.map((tag) => tag), } buildRequest(ctx, operation, def, meta, dynamicPathParams) @@ -191,14 +213,17 @@ export class OpenAPIGenerator { operation = meta.spec(operation) } - const routeKey = `${method} ${toOpenAPIPath(httpPath, dynamicPathParams?.map(p => ({ ...p, parameterName: '' })))}` + const routeKey = `${method} ${toOpenAPIPath( + httpPath, + dynamicPathParams?.map((p) => ({ ...p, parameterName: '' })), + )}` const routeOwner = routeOwners.get(routeKey) if (routeOwner !== undefined) { throw new OpenAPIGeneratorError( - `${method.toUpperCase()} ${openApiPath} is already documented by the procedure at ${routeOwner}.\n` - + ` OpenAPI allows one operation per method and path (path param names are ignored).\n` - + ` Fix: change the method or path of one of them.`, + `${method.toUpperCase()} ${openApiPath} is already documented by the procedure at ${routeOwner}.\n` + + ` OpenAPI allows one operation per method and path (path param names are ignored).\n` + + ` Fix: change the method or path of one of them.`, ) } @@ -209,8 +234,8 @@ export class OpenAPIGenerator { if (operationIdOwner !== undefined) { throw new OpenAPIGeneratorError( - `operationId "${operationId}" is already used by the procedure at ${operationIdOwner}.\n` - + ` OpenAPI requires operationIds to be unique. Fix: give one of them a different operationId.`, + `operationId "${operationId}" is already used by the procedure at ${operationIdOwner}.\n` + + ` OpenAPI requires operationIds to be unique. Fix: give one of them a different operationId.`, ) } @@ -222,8 +247,7 @@ export class OpenAPIGenerator { doc.paths ??= {} doc.paths[openApiPath] ??= {} doc.paths[openApiPath][method] = operation - } - catch (e) { + } catch (e) { if (!(e instanceof OpenAPIGeneratorError)) { throw e } @@ -241,28 +265,40 @@ export class OpenAPIGenerator { return this.serializer.serializeJson(versioned).json as OpenAPIDocument } - private convertSchema(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [JsonSchema, boolean] { + private convertSchema( + schema: AnySchema | undefined, + direction: JsonSchemaConverterDirection, + ): [JsonSchema, boolean] { const [jsonSchema, optional] = this.converter.convert(schema as any, direction) // composing or hoisting would retarget root-relative refs (`#`, `#/properties/...`), // so move a schema that points into itself under a def first return [hoistRecursiveRefToDef(strip$schemaField(jsonSchema)), optional] } - private convertSchemas(schemas: AnySchema[] | undefined, direction: JsonSchemaConverterDirection): [JsonSchema, boolean] { + private convertSchemas( + schemas: AnySchema[] | undefined, + direction: JsonSchemaConverterDirection, + ): [JsonSchema, boolean] { if (!schemas || schemas.length <= 1) { return this.convertSchema(schemas?.[0], direction) } - const results = schemas.map(s => this.convertSchema(s, direction)) + const results = schemas.map((s) => this.convertSchema(s, direction)) return [ - combineJsonSchemasWithComposition('allOf', results.map(([jsonSchema]) => jsonSchema)), + combineJsonSchemasWithComposition( + 'allOf', + results.map(([jsonSchema]) => jsonSchema), + ), results.every(([, optional]) => optional), ] } } -function toVersionedOpenAPIDocument(doc: OpenAPIV3_2.OpenAPIObject, version: OpenAPIVersion): OpenAPIDocument { +function toVersionedOpenAPIDocument( + doc: OpenAPIV3_2.OpenAPIObject, + version: OpenAPIVersion, +): OpenAPIDocument { const downgraded = version.startsWith('3.2.') ? doc : version.startsWith('3.1.') @@ -276,6 +312,6 @@ function strip$schemaField(schema: JsonSchema): JsonSchema { if (typeof schema !== 'object') { return schema } - const { $schema, ...rest } = schema + const { $schema: _schema, ...rest } = schema return rest } diff --git a/packages/openapi/src/openapi-json-serializer.test-d.ts b/packages/openapi/src/openapi-json-serializer.test-d.ts index fdfaabc8ca..050a1eaca0 100644 --- a/packages/openapi/src/openapi-json-serializer.test-d.ts +++ b/packages/openapi/src/openapi-json-serializer.test-d.ts @@ -1,7 +1,8 @@ import type { RPCJsonSerialization, RPCJsonSerializer } from '@orpc/client' import type { Public } from '@orpc/shared' -import type { OpenAPIJsonSerialization, OpenAPIJsonSerializer } from './openapi-json-serializer' -import type { OpenAPISerializer } from './openapi-serializer' + +import type { OpenAPIJsonSerialization, OpenAPIJsonSerializer } from './openapi-json-serializer.ts' +import type { OpenAPISerializer } from './openapi-serializer.ts' describe('openAPIJsonSerializer', () => { it('is assignable to RPCJsonSerializer', () => { diff --git a/packages/openapi/src/openapi-json-serializer.test.ts b/packages/openapi/src/openapi-json-serializer.test.ts index 8e0b198a5f..feec46a0b3 100644 --- a/packages/openapi/src/openapi-json-serializer.test.ts +++ b/packages/openapi/src/openapi-json-serializer.test.ts @@ -1,4 +1,4 @@ -import { OpenAPIJsonSerializer } from './openapi-json-serializer' +import { OpenAPIJsonSerializer } from './openapi-json-serializer.ts' class Person { constructor( @@ -50,18 +50,26 @@ describe('openAPIJsonSerializer', () => { it('serializes bigint to string', () => { expect(serializer.serializeJson(42n).json).toBe('42') - expect(serializer.serializeJson(99999999999999999999999999999n).json).toBe('99999999999999999999999999999') + expect(serializer.serializeJson(99999999999999999999999999999n).json).toBe( + '99999999999999999999999999999', + ) }) it('serializes URL to string', () => { - expect(serializer.serializeJson(new URL('https://dinwwwh.com')).json).toBe('https://dinwwwh.com/') + expect(serializer.serializeJson(new URL('https://dinwwwh.com')).json).toBe( + 'https://dinwwwh.com/', + ) }) it('serializes Set to array and Map to entries, converting nested values', () => { expect(serializer.serializeJson(new Set([1, 2, 3])).json).toEqual([1, 2, 3]) expect(serializer.serializeJson(new Map([['a', 1]])).json).toEqual([['a', 1]]) - expect(serializer.serializeJson(new Set([new Date('2023-01-01')])).json).toEqual(['2023-01-01T00:00:00.000Z']) - expect(serializer.serializeJson(new Map([[new Date('2023-01-01'), 1n]])).json).toEqual([['2023-01-01T00:00:00.000Z', '1']]) + expect(serializer.serializeJson(new Set([new Date('2023-01-01')])).json).toEqual([ + '2023-01-01T00:00:00.000Z', + ]) + expect(serializer.serializeJson(new Map([[new Date('2023-01-01'), 1n]])).json).toEqual([ + ['2023-01-01T00:00:00.000Z', '1'], + ]) }) it('serializes a realistic API response in one pass', () => { @@ -96,11 +104,15 @@ describe('openAPIJsonSerializer', () => { }) it('skips toJSON methods', () => { - expect(serializer.serializeJson({ value: { toJSON: () => 'hello' } }).json).toEqual({ value: {} }) + expect(serializer.serializeJson({ value: { toJSON: () => 'hello' } }).json).toEqual({ + value: {}, + }) }) it('keeps non-function toJSON properties', () => { - expect(serializer.serializeJson({ value: { toJSON: 'hello' } }).json).toEqual({ value: { toJSON: 'hello' } }) + expect(serializer.serializeJson({ value: { toJSON: 'hello' } }).json).toEqual({ + value: { toJSON: 'hello' }, + }) }) it('collects blobs and maps', () => { @@ -115,7 +127,10 @@ describe('openAPIJsonSerializer', () => { const b = new Blob(['b']) const { maps, blobs } = serializer.serializeJson({ nested: { a }, list: [1, b] }) - expect(maps).toEqual([['nested', 'a'], ['list', 1]]) + expect(maps).toEqual([ + ['nested', 'a'], + ['list', 1], + ]) expect(blobs).toEqual([a, b]) }) @@ -128,7 +143,11 @@ describe('openAPIJsonSerializer', () => { describe('deserialize', () => { it('restores blobs at mapped paths', () => { const blob = new Blob(['hello']) - const result = serializer.deserializeJson({ json: { file: null }, maps: [['file']], blobs: [blob] }) + const result = serializer.deserializeJson({ + json: { file: null }, + maps: [['file']], + blobs: [blob], + }) expect((result as any).file).toBe(blob) }) @@ -152,27 +171,32 @@ describe('openAPIJsonSerializer', () => { const custom = new OpenAPIJsonSerializer({ handlers: { date: { - condition: data => data instanceof Date, + condition: (data) => data instanceof Date, serialize: (value: Date) => `___TEST___${value.getTime()}`, }, }, }) const date = new Date('2023-01-01') - expect(custom.serializeJson({ value: date }).json).toEqual({ value: `___TEST___${date.getTime()}` }) + expect(custom.serializeJson({ 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.serializeJson({ value: date, list: [undefined] }).json).toEqual({ value: date, list: [null] }) + expect(custom.serializeJson({ value: date, list: [undefined] }).json).toEqual({ + value: date, + list: [null], + }) }) it('supports custom handlers, and keeps serializing non-terminal results', () => { const custom = new OpenAPIJsonSerializer({ handlers: { person: { - condition: data => data instanceof Person, + condition: (data) => data instanceof Person, serialize: (data: Person) => data.toJSON(), }, }, @@ -189,7 +213,7 @@ describe('openAPIJsonSerializer', () => { const custom = new OpenAPIJsonSerializer({ handlers: { person: { - condition: data => data instanceof Person, + condition: (data) => data instanceof Person, serialize: (data: Person) => data.toJSON(), isTerminal: true, }, @@ -205,26 +229,28 @@ describe('openAPIJsonSerializer', () => { const custom = new OpenAPIJsonSerializer({ handlers: { undefined: { - condition: data => data === undefined, + condition: (data) => data === undefined, serialize: () => null, isTerminal: true, }, }, }) - expect(custom.serializeJson({ - date: new Date('2023-01-01'), - invalidDate: new Date('Invalid'), - nan: Number.NaN, - infinity: Number.POSITIVE_INFINITY, - negativeInfinity: Number.NEGATIVE_INFINITY, - url: new URL('https://orpc.dev'), - set: new Set([1, 2]), - map: new Map([['a', 1]]), - bigint: 123n, - list: [undefined], - plain: 'text', - }).json).toEqual({ + expect( + custom.serializeJson({ + date: new Date('2023-01-01'), + invalidDate: new Date('Invalid'), + nan: Number.NaN, + infinity: Number.POSITIVE_INFINITY, + negativeInfinity: Number.NEGATIVE_INFINITY, + url: new URL('https://orpc.dev'), + set: new Set([1, 2]), + map: new Map([['a', 1]]), + bigint: 123n, + list: [undefined], + plain: 'text', + }).json, + ).toEqual({ date: '2023-01-01T00:00:00.000Z', invalidDate: null, nan: null, @@ -247,14 +273,16 @@ describe('openAPIJsonSerializer', () => { const custom = new OpenAPIJsonSerializer({ handlers: { blobContainer: { - condition: data => data instanceof BlobContainer, + condition: (data) => data instanceof BlobContainer, serialize: (data: BlobContainer) => data.blob, isTerminal: true, }, }, }) - const serialized = custom.serializeJson({ file: new BlobContainer(new Blob(['hello'], { type: 'text/plain' })) }) + const serialized = custom.serializeJson({ + file: new BlobContainer(new Blob(['hello'], { type: 'text/plain' })), + }) expect(serialized.json).toEqual({ file: expect.any(Blob) }) expect(serialized.maps).toEqual([['file']]) @@ -269,22 +297,22 @@ describe('openAPIJsonSerializer', () => { handlers: { ...extraHandlers, negZero: { - condition: data => Object.is(data, -0), + condition: (data) => Object.is(data, -0), serialize: () => '-0', isTerminal: true, }, secret: { - condition: data => typeof data === 'string' && data.startsWith('secret:'), + condition: (data) => typeof data === 'string' && data.startsWith('secret:'), serialize: () => '***', isTerminal: true, }, flag: { - condition: data => typeof data === 'boolean', + condition: (data) => typeof data === 'boolean', serialize: (data: boolean) => (data ? 'yes' : 'no'), isTerminal: true, }, nil: { - condition: data => data === null, + condition: (data) => data === null, serialize: () => 'null', isTerminal: true, }, @@ -292,13 +320,17 @@ describe('openAPIJsonSerializer', () => { }) it('runs custom handlers for strings, booleans, finite numbers and null', () => { - expect(custom.serializeJson({ zero: -0, text: 'secret:abc', flag: true, nil: null }).json) - .toEqual({ zero: '-0', text: '***', flag: 'yes', nil: 'null' }) + expect( + custom.serializeJson({ zero: -0, text: 'secret:abc', flag: true, nil: null }).json, + ).toEqual({ zero: '-0', text: '***', flag: 'yes', nil: 'null' }) }) it('leaves primitives no custom handler matches untouched', () => { - expect(custom.serializeJson({ zero: 0, num: 1.5, text: 'plain' }).json) - .toEqual({ zero: 0, num: 1.5, text: 'plain' }) + expect(custom.serializeJson({ zero: 0, num: 1.5, text: 'plain' }).json).toEqual({ + zero: 0, + num: 1.5, + text: 'plain', + }) }) it('still gives built-in handlers precedence over custom handlers', () => { @@ -306,14 +338,18 @@ describe('openAPIJsonSerializer', () => { handlers: { ...extraHandlers, number: { - condition: data => typeof data === 'number', + condition: (data) => typeof data === 'number', serialize: (data: number) => `n${data}`, isTerminal: true, }, }, }) - expect(numbers.serializeJson([1, Number.NaN, Number.POSITIVE_INFINITY]).json).toEqual(['n1', null, null]) + expect(numbers.serializeJson([1, Number.NaN, Number.POSITIVE_INFINITY]).json).toEqual([ + 'n1', + null, + null, + ]) }) }) @@ -329,34 +365,48 @@ describe('openAPIJsonSerializer', () => { expect((Object.prototype as any).polluted).toBeUndefined() }) - it.each(['doesNotExist', '__proto__', 'constructor', 'prototype'])('throws on invalid blob map segment "%s" to prevent prototype pollution', (segment) => { - expect( - () => serializer.deserializeJson({ json: { o: {} }, blobs: [new Blob()], maps: [[segment]] }), - ).toThrowError(`Invalid OpenAPI serialized data: segment "${segment}" does not exist.`) - - expect( - () => serializer.deserializeJson({ json: { o: {} }, blobs: [new Blob()], maps: [['o', segment]] }), - ).toThrowError(`Invalid OpenAPI serialized data: segment "${segment}" does not exist.`) - - expect( - () => serializer.deserializeJson({ json: { o: {} }, blobs: [new Blob()], maps: [[segment, 'o']] }), - ).toThrowError(`Invalid OpenAPI serialized data: segment "${segment}" does not exist.`) - }) + it.each(['doesNotExist', '__proto__', 'constructor', 'prototype'])( + 'throws on invalid blob map segment "%s" to prevent prototype pollution', + (segment) => { + expect(() => + serializer.deserializeJson({ json: { o: {} }, blobs: [new Blob()], maps: [[segment]] }), + ).toThrowError(`Invalid OpenAPI serialized data: segment "${segment}" does not exist.`) + + expect(() => + serializer.deserializeJson({ + json: { o: {} }, + blobs: [new Blob()], + maps: [['o', segment]], + }), + ).toThrowError(`Invalid OpenAPI serialized data: segment "${segment}" does not exist.`) + + expect(() => + serializer.deserializeJson({ + json: { o: {} }, + blobs: [new Blob()], + maps: [[segment, 'o']], + }), + ).toThrowError(`Invalid OpenAPI serialized data: segment "${segment}" does not exist.`) + }, + ) it('throws when a blobs entry is not a Blob', () => { for (const value of [4294967295, 'text', null, {}, []]) { - expect(() => serializer.deserializeJson({ json: [null], blobs: [value as any], maps: [[0]] })) - .toThrowError('Invalid OpenAPI serialized data: blob 0 is not a Blob.') + expect(() => + serializer.deserializeJson({ json: [null], blobs: [value as any], maps: [[0]] }), + ).toThrowError('Invalid OpenAPI serialized data: blob 0 is not a Blob.') } - expect(() => serializer.deserializeJson({ json: [null, null], blobs: [new Blob()], maps: [[0], [1]] })) - .toThrowError('Invalid OpenAPI serialized data: blob 1 is not a Blob.') + expect(() => + serializer.deserializeJson({ json: [null, null], blobs: [new Blob()], maps: [[0], [1]] }), + ).toThrowError('Invalid OpenAPI serialized data: blob 1 is not a Blob.') }) - /* eslint-disable no-proto, no-restricted-properties */ it('serializes own __proto__ keys as plain data', () => { // JSON.parse creates own __proto__ properties, exactly like a real request body - const { json } = serializer.serializeJson(JSON.parse('{"__proto__": {"polluted": true}}')) as any + const { json } = serializer.serializeJson( + JSON.parse('{"__proto__": {"polluted": true}}'), + ) as any expect(Object.hasOwn(json, '__proto__')).toBe(true) expect(json.__proto__).toEqual({ polluted: true }) @@ -398,6 +448,5 @@ describe('openAPIJsonSerializer', () => { expect(result.constructor.prototype.polluted).toBe(blob) }) - /* eslint-enable no-proto, no-restricted-properties */ }) }) diff --git a/packages/openapi/src/openapi-json-serializer.ts b/packages/openapi/src/openapi-json-serializer.ts index f5bbe924e2..fb06c61290 100644 --- a/packages/openapi/src/openapi-json-serializer.ts +++ b/packages/openapi/src/openapi-json-serializer.ts @@ -1,9 +1,9 @@ import type { Segment } from '@orpc/shared' import { copyOnWrite, isPlainObject, NullProtoObj } from '@orpc/shared' -export type OpenAPIJsonSerialization - = | { json: unknown, maps?: undefined, blobs?: undefined } - | { json: unknown, maps: Segment[][], blobs: Blob[] } +export type OpenAPIJsonSerialization = + | { json: unknown; maps?: undefined; blobs?: undefined } + | { json: unknown; maps: Segment[][]; blobs: Blob[] } export interface OpenAPIJsonSerializerHandler { condition(value: unknown): boolean @@ -181,7 +181,10 @@ export class OpenAPIJsonSerializer { if (!inlineBuiltInHandlers) { handlerEntries = [] - for (const handler of Object.values({ ...DEFAULT_OPEN_API_JSON_SERIALIZER_HANDLERS, ...customHandlers })) { + for (const handler of Object.values({ + ...DEFAULT_OPEN_API_JSON_SERIALIZER_HANDLERS, + ...customHandlers, + })) { if (handler !== undefined) { handlerEntries.push(handler) } @@ -206,7 +209,12 @@ export class OpenAPIJsonSerializer { * `segments` is a shared mutable stack (push/pop while walking), * so it must be copied before being stored in `maps`. */ - private serializeValue(data: unknown, segments: Segment[], maps: Segment[][], blobs: Blob[]): unknown { + private serializeValue( + data: unknown, + segments: Segment[], + maps: Segment[][], + blobs: Blob[], + ): unknown { const handlerEntries = this.handlerEntries /** @@ -353,7 +361,9 @@ export class OpenAPIJsonSerializer { preSegment = segments[j]! if (!Object.hasOwn(currentRef, preSegment)) { - throw new TypeError(`Invalid OpenAPI serialized data: segment "${preSegment}" does not exist.`) + throw new TypeError( + `Invalid OpenAPI serialized data: segment "${preSegment}" does not exist.`, + ) } } diff --git a/packages/openapi/src/openapi-serializer.test.ts b/packages/openapi/src/openapi-serializer.test.ts index 5bb2defbb4..820ab3a805 100644 --- a/packages/openapi/src/openapi-serializer.test.ts +++ b/packages/openapi/src/openapi-serializer.test.ts @@ -1,7 +1,8 @@ import { createORPCErrorFromJson, ORPCError } from '@orpc/client' import { isAsyncIteratorObject } from '@orpc/shared' import { ErrorEvent } from '@standard-server/core' -import { OpenAPISerializer } from './openapi-serializer' + +import { OpenAPISerializer } from './openapi-serializer.ts' describe('openAPISerializer', () => { const serializer = new OpenAPISerializer() @@ -52,19 +53,24 @@ describe('openAPISerializer', () => { }) it('omits NaN and ±Infinity from FormData, matching their null JSON form', () => { - const result = serializer.serialize({ - nan: Number.NaN, - max: Number.POSITIVE_INFINITY, - min: Number.NEGATIVE_INFINITY, - count: 1, - }, { asFormData: true }) as FormData + const result = serializer.serialize( + { + nan: Number.NaN, + max: Number.POSITIVE_INFINITY, + min: Number.NEGATIVE_INFINITY, + count: 1, + }, + { asFormData: true }, + ) as FormData expect(Array.from(result.keys())).toEqual(['count']) }) it('skips useFormDataForBlobFields when false', () => { const blob = new Blob(['hello']) - expect(serializer.serialize({ file: blob }, { useFormDataForBlobFields: false })).not.toBeInstanceOf(FormData) + expect( + serializer.serialize({ file: blob }, { useFormDataForBlobFields: false }), + ).not.toBeInstanceOf(FormData) }) it('always returns FormData when asFormData is true, using bracket notation', () => { @@ -88,14 +94,18 @@ describe('openAPISerializer', () => { }) it('call-site options override constructor defaults', () => { - const s = new OpenAPISerializer({ serialize: { asFormData: true, useFormDataForBlobFields: false } }) + 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) + expect( + s.serialize({ file: blob }, { asFormData: false, useFormDataForBlobFields: true }), + ).toBeInstanceOf(FormData) }) describe('asyncIteratorObject', () => { @@ -104,7 +114,9 @@ describe('openAPISerializer', () => { } it('returns an AsyncIteratorObject and serializes yielded values', async () => { - const result = serializer.serialize(toAsyncIter([new Date('2023-01-01'), 42n])) as AsyncIteratorObject + const result = serializer.serialize( + toAsyncIter([new Date('2023-01-01'), 42n]), + ) as AsyncIteratorObject expect(result).toSatisfy(isAsyncIteratorObject) const collected: unknown[] = [] @@ -113,7 +125,9 @@ describe('openAPISerializer', () => { }) it('passes through undefined yielded values as-is', async () => { - const result = serializer.serialize(toAsyncIter([undefined, 'value'])) as AsyncIteratorObject + const result = serializer.serialize( + toAsyncIter([undefined, 'value']), + ) as AsyncIteratorObject expect(result).toSatisfy(isAsyncIteratorObject) const collected: unknown[] = [] @@ -133,9 +147,11 @@ describe('openAPISerializer', () => { 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 + const result = serializer.serialize( + (async function* () { + throw error + })(), + ) as AsyncIteratorObject await expect(result.next()).rejects.toSatisfy((e: any) => { expect(e).toBeInstanceOf(ErrorEvent) @@ -151,9 +167,11 @@ describe('openAPISerializer', () => { 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 + const result = serializer.serialize( + (async function* () { + throw error + })(), + ) as AsyncIteratorObject await expect(result.next()).rejects.toSatisfy((e: any) => { expect(e).toBeInstanceOf(ErrorEvent) @@ -173,18 +191,24 @@ describe('openAPISerializer', () => { describe('deserialize', () => { it('uses OpenAPIJsonSerializer for deserialization', () => { - expect(serializer.deserialize({ name: 'test', value: 42 })).toEqual({ name: 'test', value: 42 }) + 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 - }], + [ + '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') @@ -237,7 +261,9 @@ describe('openAPISerializer', () => { } it('returns an AsyncIteratorObject and passes through yielded values', async () => { - const result = serializer.deserialize(toAsyncIter([{ a: 1 }, { b: 2 }])) as AsyncIterable + const result = serializer.deserialize( + toAsyncIter([{ a: 1 }, { b: 2 }]), + ) as AsyncIterable expect(result).toSatisfy(isAsyncIteratorObject) const collected: unknown[] = [] @@ -246,7 +272,9 @@ describe('openAPISerializer', () => { }) it('passes through undefined yielded values as-is', async () => { - const result = serializer.deserialize(toAsyncIter([undefined, { a: 1 }])) as AsyncIterable + const result = serializer.deserialize( + toAsyncIter([undefined, { a: 1 }]), + ) as AsyncIterable expect(result).toSatisfy(isAsyncIteratorObject) const collected: unknown[] = [] @@ -258,9 +286,11 @@ describe('openAPISerializer', () => { const error = new ErrorEvent( new ORPCError('BAD_GATEWAY', { data: { reason: 'upstream' } }).toJSON(), ) - const result = serializer.deserialize((async function* () { - throw error - })()) as AsyncIteratorObject + const result = serializer.deserialize( + (async function* () { + throw error + })(), + ) as AsyncIteratorObject await expect(result.next()).rejects.toSatisfy((e: any) => { expect(e).toBeInstanceOf(ORPCError) @@ -280,12 +310,16 @@ describe('openAPISerializer', () => { data: { reason: 'upstream' }, }) - const serialized = serializer.serialize((async function* () { - throw error - })()) as AsyncIteratorObject + const serialized = serializer.serialize( + (async function* () { + throw error + })(), + ) as AsyncIteratorObject const event = await serialized.next().then( - () => { throw new Error('expected the iterator to reject') }, + () => { + throw new Error('expected the iterator to reject') + }, (e: unknown) => e as ErrorEvent, ) @@ -309,18 +343,22 @@ describe('openAPISerializer', () => { 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 + const result = serializer.deserialize( + (async function* () { + throw error + })(), + ) as AsyncIteratorObject await expect(result.next()).rejects.toBe(error) }) it('passes through non-ErrorEvent errors', async () => { const error = new Error('unexpected') - const result = serializer.deserialize((async function* () { - throw error - })()) as AsyncIteratorObject + const result = serializer.deserialize( + (async function* () { + throw error + })(), + ) as AsyncIteratorObject await expect(result.next()).rejects.toBe(error) }) @@ -332,7 +370,7 @@ describe('openAPISerializer', () => { const s = new OpenAPISerializer({ handlers: { date: { - condition: v => v instanceof Date, + condition: (v) => v instanceof Date, serialize: (v: Date) => `___TEST___${v.getTime()}`, }, }, @@ -367,7 +405,6 @@ describe('openAPISerializer', () => { expect((Function.prototype as any).polluted).toBeUndefined() }) - /* eslint-disable no-proto, no-restricted-properties */ it('deserializes __proto__ query parameters as plain data', () => { const result = serializer.deserialize( new URLSearchParams('__proto__[polluted]=yes&safe=1'), @@ -378,7 +415,6 @@ describe('openAPISerializer', () => { expect(result.polluted).toBeUndefined() expect(result.safe).toBe('1') }) - /* eslint-enable no-proto, no-restricted-properties */ it('deserializes constructor.prototype form fields as plain data', () => { const form = new FormData() diff --git a/packages/openapi/src/openapi-serializer.ts b/packages/openapi/src/openapi-serializer.ts index 8e907449e0..9034ee17c1 100644 --- a/packages/openapi/src/openapi-serializer.ts +++ b/packages/openapi/src/openapi-serializer.ts @@ -1,11 +1,17 @@ -import type { StandardBody } from '@standard-server/core' -import type { BracketNotationSerializerOptions } from './bracket-notation' -import type { OpenAPIJsonSerializerOptions } from './openapi-json-serializer' -import { createORPCErrorFromJson, isORPCErrorJson, toORPCError, wrapAsyncIteratorPreservingEventMeta } from '@orpc/client' +import { + createORPCErrorFromJson, + isORPCErrorJson, + toORPCError, + wrapAsyncIteratorPreservingEventMeta, +} from '@orpc/client' import { isAsyncIteratorObject } from '@orpc/shared' +import type { StandardBody } from '@standard-server/core' import { ErrorEvent } from '@standard-server/core' -import { BracketNotationSerializer } from './bracket-notation' -import { OpenAPIJsonSerializer } from './openapi-json-serializer' + +import type { BracketNotationSerializerOptions } from './bracket-notation.ts' +import { BracketNotationSerializer } from './bracket-notation.ts' +import type { OpenAPIJsonSerializerOptions } from './openapi-json-serializer.ts' +import { OpenAPIJsonSerializer } from './openapi-json-serializer.ts' export interface OpenAPISerializerSerializeOptions { /** @@ -46,9 +52,7 @@ export class OpenAPISerializer extends OpenAPIJsonSerializer { private readonly bracketNotation: BracketNotationSerializer private readonly defaultSerializeOptions: OpenAPISerializerSerializeOptions | undefined - constructor( - { bracketNotation, serialize, ...options }: OpenAPISerializerOptions = {}, - ) { + constructor({ bracketNotation, serialize, ...options }: OpenAPISerializerOptions = {}) { super(options) this.bracketNotation = new BracketNotationSerializer(bracketNotation) this.defaultSerializeOptions = serialize @@ -71,23 +75,33 @@ export class OpenAPISerializer extends OpenAPIJsonSerializer { return { done: result.done, value: this.serializePayload(result.value, false, false) } }, - mapError: e => new ErrorEvent( - this.serializePayload(toORPCError(e).toJSON(), false, false), - { cause: e }, - ), + mapError: (e) => + new ErrorEvent(this.serializePayload(toORPCError(e).toJSON(), false, false), { + cause: e, + }), }) } } - const useFormDataForBlobFields = options.useFormDataForBlobFields ?? this.defaultSerializeOptions?.useFormDataForBlobFields ?? true + const useFormDataForBlobFields = + options.useFormDataForBlobFields ?? + this.defaultSerializeOptions?.useFormDataForBlobFields ?? + true const asFormData = options.asFormData ?? this.defaultSerializeOptions?.asFormData ?? false return this.serializePayload(data, useFormDataForBlobFields, asFormData) } - private serializePayload(value: unknown, useFormDataForBlobFields: boolean, asFormData: boolean): unknown { + private serializePayload( + value: unknown, + useFormDataForBlobFields: boolean, + asFormData: boolean, + ): unknown { const { json, blobs } = this.serializeJson(value) - if (!asFormData && (json instanceof Blob || json === undefined || !blobs?.length || !useFormDataForBlobFields)) { + if ( + !asFormData && + (json instanceof Blob || json === undefined || !blobs?.length || !useFormDataForBlobFields) + ) { return json } @@ -96,8 +110,8 @@ export class OpenAPISerializer extends OpenAPIJsonSerializer { for (const [path, value] of this.bracketNotation.serialize(json)) { if (value instanceof Blob) { form.append(path, value) - } - else if (value !== undefined && value !== null) { + } else if (value !== undefined && value !== null) { + // oxlint-disable-next-line typescript/no-base-to-string form.append(path, String(value)) } } diff --git a/packages/openapi/src/plugins/index.test.ts b/packages/openapi/src/plugins/index.test.ts index dd58851aeb..89678ed026 100644 --- a/packages/openapi/src/plugins/index.test.ts +++ b/packages/openapi/src/plugins/index.test.ts @@ -1,5 +1,5 @@ it('exports OpenAPIReferenceHandlerPlugin', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ OpenAPIReferenceHandlerPlugin: expect.any(Function), }) }) diff --git a/packages/openapi/src/plugins/index.ts b/packages/openapi/src/plugins/index.ts index 89b47969eb..8e36e57400 100644 --- a/packages/openapi/src/plugins/index.ts +++ b/packages/openapi/src/plugins/index.ts @@ -1 +1 @@ -export * from './openapi-reference' +export * from './openapi-reference.ts' diff --git a/packages/openapi/src/plugins/openapi-reference.test.ts b/packages/openapi/src/plugins/openapi-reference.test.ts index 95b76d3d4b..0ca866752f 100644 --- a/packages/openapi/src/plugins/openapi-reference.test.ts +++ b/packages/openapi/src/plugins/openapi-reference.test.ts @@ -1,5 +1,5 @@ -import type { OpenAPIDocument, OpenAPIVersion } from '../types' -import { OpenAPIReferenceHandlerPlugin } from './openapi-reference' +import type { OpenAPIDocument, OpenAPIVersion } from '../types.ts' +import { OpenAPIReferenceHandlerPlugin } from './openapi-reference.ts' describe('openAPIReferenceHandlerPlugin', () => { beforeEach(() => { @@ -75,7 +75,7 @@ describe('openAPIReferenceHandlerPlugin', () => { }) const window: { onload?: () => void } = { SwaggerUIBundle, ...globals } as any - // eslint-disable-next-line no-new-func + // oxlint-disable-next-line typescript/no-implied-eval new Function('window', 'SwaggerUIBundle', script)(window, SwaggerUIBundle) window.onload!() @@ -154,10 +154,12 @@ describe('openAPIReferenceHandlerPlugin', () => { } expect(allow).toHaveBeenCalledTimes(2) - expect(allow).toHaveBeenCalledWith(expect.objectContaining({ - context: {}, - request: expect.objectContaining({ url: '/spec.json' }), - })) + expect(allow).toHaveBeenCalledWith( + expect.objectContaining({ + context: {}, + request: expect.objectContaining({ url: '/spec.json' }), + }), + ) expect(spec).not.toHaveBeenCalled() }) @@ -194,10 +196,12 @@ describe('openAPIReferenceHandlerPlugin', () => { }) expect(spec).toHaveBeenCalledOnce() - expect(spec).toHaveBeenCalledWith(expect.objectContaining({ - prefix: '/gateway', - request: expect.objectContaining({ url: '/gateway/openapi.json' }), - })) + 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({}) @@ -227,7 +231,7 @@ describe('openAPIReferenceHandlerPlugin', () => { 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("Scalar.createApiReference('#app', scalarConfig)") expect(html).toContain('const scalarConfig =') expect(html).not.toContain('rel="stylesheet"') expect(html).not.toContain('undefined') @@ -243,7 +247,7 @@ describe('openAPIReferenceHandlerPlugin', () => { providerScriptUrl: 'https://cdn.example.com/scalar.js?foo=1&bar=', providerCssUrl: 'https://cdn.example.com/scalar.css?foo=1&bar=', providerConfig: { - pageTitle: '&\'<>/', + pageTitle: "&'<>/", } as any, }) const { interceptor } = getInterceptor(plugin) @@ -257,8 +261,12 @@ describe('openAPIReferenceHandlerPlugin', () => { expect(html).toContain('Scalar & "Docs" <Guide>') expect(html).toContain('') - expect(html).toContain('') - expect(html).toContain('') + expect(html).toContain( + '', + ) + expect(html).toContain( + '', + ) expect(html).toContain('pageTitle') expect(html).toContain('\\u0026') expect(html).toContain('\\u0027') @@ -340,7 +348,7 @@ describe('openAPIReferenceHandlerPlugin', () => { providerCssUrl: 'https://cdn.example.com/swagger.css?foo=1&bar=', providerConfig: { tryItOutEnabled: true, - customOption: '&\'<>/', + customOption: "&'<>/", presets: ['SwaggerUIBundle.presets.apis', 'SwaggerUIStandalonePreset'], plugins: ['SwaggerUIBundle.plugins.DownloadUrl', 'MyPlugins.logger', 'Missing.plugin'], } as any, @@ -356,8 +364,12 @@ describe('openAPIReferenceHandlerPlugin', () => { expect(html).toContain('Swagger & "Docs" <Guide>') expect(html).toContain('') - expect(html).toContain('') - expect(html).toContain('') + expect(html).toContain( + '', + ) + expect(html).toContain( + '', + ) expect(html).toContain('tryItOutEnabled') expect(html).toContain('customOption') expect(html).toContain('\\u0026') @@ -370,7 +382,7 @@ describe('openAPIReferenceHandlerPlugin', () => { const MyPlugins = { logger: { plugin: 'logger' } } const { bundle, config } = runSwaggerScript(html, { SwaggerUIStandalonePreset, MyPlugins }) - expect(config.customOption).toBe('&\'<>/') + expect(config.customOption).toBe("&'<>/") expect(config.presets).toEqual([bundle.presets.apis, SwaggerUIStandalonePreset]) expect(config.plugins).toEqual([bundle.plugins.DownloadUrl, MyPlugins.logger, undefined]) }) diff --git a/packages/openapi/src/plugins/openapi-reference.ts b/packages/openapi/src/plugins/openapi-reference.ts index ba8315ff2d..0d1a0f8ff4 100644 --- a/packages/openapi/src/plugins/openapi-reference.ts +++ b/packages/openapi/src/plugins/openapi-reference.ts @@ -1,18 +1,31 @@ import type { Context } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptorOptions } from '@orpc/server/standard' +import type { + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptorOptions, +} from '@orpc/server/standard' import type { Promisable, Value } from '@orpc/shared' +import { + getTracer, + matchesHttpPath, + mergeHttpPath, + stringifyJSON, + toArray, + value, +} from '@orpc/shared' import type { ApiReferenceConfiguration as ScalarProviderConfig } from '@scalar/api-reference' import type { StandardUrl } from '@standard-server/core' import type { SwaggerUIOptions } from 'swagger-ui' -import type { OpenAPIDocument, OpenAPIVersion } from '../types' -import { getTracer, matchesHttpPath, mergeHttpPath, stringifyJSON, toArray, value } from '@orpc/shared' + +import type { OpenAPIDocument, OpenAPIVersion } from '../types.ts' export type OpenAPIReferenceHandlerPluginProvider = 'scalar' | 'swagger' -export interface OpenAPIReferenceHandlerPluginScalarConfig extends Partial { -} +export interface OpenAPIReferenceHandlerPluginScalarConfig extends Partial {} -export interface OpenAPIReferenceHandlerPluginSwaggerConfig extends Partial> { +export interface OpenAPIReferenceHandlerPluginSwaggerConfig extends Partial< + Omit +> { dom_id?: undefined /** @@ -32,12 +45,18 @@ export interface OpenAPIReferenceHandlerPluginSwaggerConfig extends Partial { +export interface OpenAPIReferenceHandlerPluginOptions< + T extends Context, + TProvider extends OpenAPIReferenceHandlerPluginProvider, +> { /** * A static or dynamic OpenAPI document to serve. * Receives routing interceptor options when provided as a function. */ - spec: Value>, [StandardHandlerRoutingInterceptorOptions]> + spec: Value< + Promisable>, + [StandardHandlerRoutingInterceptorOptions] + > /** * Determines whether the docs UI and OpenAPI JSON are allowed to be served for a request. @@ -65,11 +84,11 @@ export interface OpenAPIReferenceHandlerPluginOptions['spec'] private readonly allow: OpenAPIReferenceHandlerPluginOptions['allow'] - 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 specPath: Exclude< + OpenAPIReferenceHandlerPluginOptions['specPath'], + undefined + > + private readonly provider: Exclude< + OpenAPIReferenceHandlerPluginOptions['provider'], + undefined + > + private readonly providerConfig: OpenAPIReferenceHandlerPluginOptions< + T, + TProvider + >['providerConfig'] + private readonly providerScriptUrl: OpenAPIReferenceHandlerPluginOptions< + T, + TProvider + >['providerScriptUrl'] + private readonly providerCssUrl: OpenAPIReferenceHandlerPluginOptions< + T, + TProvider + >['providerCssUrl'] + private readonly docsPath: Exclude< + OpenAPIReferenceHandlerPluginOptions['docsPath'], + undefined + > private readonly docsTitle: OpenAPIReferenceHandlerPluginOptions['docsTitle'] - private readonly docsHead: Exclude['docsHead'], undefined> + private readonly docsHead: Exclude< + OpenAPIReferenceHandlerPluginOptions['docsHead'], + undefined + > constructor(options: OpenAPIReferenceHandlerPluginOptions) { this.spec = options.spec this.allow = options.allow this.specPath = options.specPath ?? '/spec.json' - this.provider = options.provider ?? 'scalar' as TProvider + this.provider = options.provider ?? ('scalar' as TProvider) this.providerConfig = options.providerConfig this.providerScriptUrl = options.providerScriptUrl this.providerCssUrl = options.providerCssUrl @@ -162,19 +202,23 @@ export class OpenAPIReferenceHandlerPlugin< const isSpecPath = matchesHttpPath( routingInterceptorOptions.request.url, - routingInterceptorOptions.prefix ? mergeHttpPath(routingInterceptorOptions.prefix, this.specPath) : this.specPath, + routingInterceptorOptions.prefix + ? mergeHttpPath(routingInterceptorOptions.prefix, this.specPath) + : this.specPath, ) const isDocsPath = matchesHttpPath( routingInterceptorOptions.request.url, - routingInterceptorOptions.prefix ? mergeHttpPath(routingInterceptorOptions.prefix, this.docsPath) : this.docsPath, + routingInterceptorOptions.prefix + ? mergeHttpPath(routingInterceptorOptions.prefix, this.docsPath) + : this.docsPath, ) if (!isSpecPath && !isDocsPath) { return result } - if (await value(this.allow, routingInterceptorOptions) === false) { + if ((await value(this.allow, routingInterceptorOptions)) === false) { return result } @@ -182,7 +226,9 @@ export class OpenAPIReferenceHandlerPlugin< const spec = await value(this.spec, routingInterceptorOptions) if (isSpecPath) { - span?.updateName(`${routingInterceptorOptions.request.method} ${routingInterceptorOptions.request.url} (openapi spec)`) + span?.updateName( + `${routingInterceptorOptions.request.method} ${routingInterceptorOptions.request.url} (openapi spec)`, + ) const specFile = new File([stringifyJSON(spec)], `${spec.info.title}.json`, { type: 'application/json', @@ -190,14 +236,18 @@ export class OpenAPIReferenceHandlerPlugin< return { matched: true, response: { status: 200, headers: {}, body: specFile } } } - span?.updateName(`${routingInterceptorOptions.request.method} ${routingInterceptorOptions.request.url} (${this.provider} ui)`) + span?.updateName( + `${routingInterceptorOptions.request.method} ${routingInterceptorOptions.request.url} (${this.provider} ui)`, + ) - const docsTitle = (await value(this.docsTitle, routingInterceptorOptions)) ?? spec.info.title + 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 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', @@ -240,14 +290,13 @@ export class OpenAPIReferenceHandlerPlugin< ` - } - - else { - const scriptUrl = this.providerScriptUrl ?? 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' + } 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, + ...(this.providerConfig as any), } html = ` diff --git a/packages/openapi/src/router-utils.test.ts b/packages/openapi/src/router-utils.test.ts index 96a2eced62..bcbaa7dbd7 100644 --- a/packages/openapi/src/router-utils.test.ts +++ b/packages/openapi/src/router-utils.test.ts @@ -1,11 +1,12 @@ import { oc } from '@orpc/contract' -import z from 'zod' -import { getOpenAPIMeta, openapi } from './meta' -import { populateRouterContractOpenAPIPaths } from './router-utils' +import * as z from 'zod' -export const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) +import { getOpenAPIMeta, openapi } from './meta.ts' +import { populateRouterContractOpenAPIPaths } from './router-utils.ts' -export const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) +export const inputSchema = z.object({ input: z.number().transform((n) => `${n}`) }) + +export const outputSchema = z.object({ output: z.number().transform((n) => `${n}`) }) describe('populateRouterContractOpenAPIPaths', () => { it('preserves existing defs and populates missing ones from the router shape', () => { @@ -21,12 +22,18 @@ describe('populateRouterContractOpenAPIPaths', () => { const populated = populateRouterContractOpenAPIPaths(contract) expect(getOpenAPIMeta(populated.pong)).toEqual({ summary: 'pong', path: '/pong/{id}' }) - expect(getOpenAPIMeta(populated.nested.pong)).toEqual({ summary: 'nested.pong', path: '/pong2/{id}' }) + expect(getOpenAPIMeta(populated.nested.pong)).toEqual({ + summary: 'nested.pong', + path: '/pong2/{id}', + }) expect(getOpenAPIMeta(populated.ping)).toEqual({ summary: 'ping', path: '/ping' }) expect(populated.ping['~orpc'].inputSchemas).toEqual([inputSchema]) - expect(getOpenAPIMeta(populated.nested.ping)).toEqual({ summary: 'nested.ping', path: '/nested/ping' }) + expect(getOpenAPIMeta(populated.nested.ping)).toEqual({ + summary: 'nested.ping', + path: '/nested/ping', + }) expect(populated.nested.ping['~orpc'].outputSchemas).toEqual([outputSchema]) }) diff --git a/packages/openapi/src/router-utils.ts b/packages/openapi/src/router-utils.ts index 1891e2dfa2..f3c00a263a 100644 --- a/packages/openapi/src/router-utils.ts +++ b/packages/openapi/src/router-utils.ts @@ -1,13 +1,16 @@ import type { RouterContract } from '@orpc/contract' import { ProcedureContract, resolveMetaPlugins } from '@orpc/contract' import { isTypescriptObject, pathToHttpPath, toArray } from '@orpc/shared' -import { getOpenAPIMeta, openapi } from './meta' -export type PopulatedContractRouterOpenAPIPaths - = T extends ProcedureContract +import { getOpenAPIMeta, openapi } from './meta.ts' + +export type PopulatedContractRouterOpenAPIPaths = + T extends ProcedureContract ? ProcedureContract : { - [K in keyof T]: T[K] extends RouterContract ? PopulatedContractRouterOpenAPIPaths : never + [K in keyof T]: T[K] extends RouterContract + ? PopulatedContractRouterOpenAPIPaths + : never } export interface PopulateRouterContractOpenAPIPathsOptions { @@ -56,10 +59,10 @@ export function populateRouterContractOpenAPIPaths( const populated: Record = {} for (const key of Object.keys(router)) { - populated[key] = populateRouterContractOpenAPIPaths( - router[key]!, - { ...options, path: [...path, key] }, - ) + populated[key] = populateRouterContractOpenAPIPaths(router[key]!, { + ...options, + path: [...path, key], + }) } return populated as any diff --git a/packages/openapi/src/types.test-d.ts b/packages/openapi/src/types.test-d.ts index 36bb08227e..3122d4bac4 100644 --- a/packages/openapi/src/types.test-d.ts +++ b/packages/openapi/src/types.test-d.ts @@ -1,9 +1,17 @@ import type { Client, ORPCError } from '@orpc/client' import type { RouterContractClient } from '@orpc/contract' -import type { AsyncIteratorClass } from '@orpc/shared' -import type { JsonifiedClient, JsonifiedValue, OpenAPIDocument, OpenAPIV3_0, OpenAPIV3_1, OpenAPIV3_2 } from './types' import { asyncIteratorObject, oc } from '@orpc/contract' -import z from 'zod' +import type { AsyncIteratorClass } from '@orpc/shared' +import * as z from 'zod' + +import type { + JsonifiedClient, + JsonifiedValue, + OpenAPIDocument, + OpenAPIV3_0, + OpenAPIV3_1, + OpenAPIV3_2, +} from './types.ts' describe('OpenAPIDocument', () => { it('resolves the document type of a version, patch segment ignored', () => { @@ -35,30 +43,45 @@ describe('JsonifiedValue', () => { expectTypeOf>>().toEqualTypeOf() expectTypeOf>().toEqualTypeOf() expectTypeOf>().toEqualTypeOf<[string, null]>() - expectTypeOf>().toEqualTypeOf<{ a: number, b: string }>() - expectTypeOf>>().toEqualTypeOf>() - expectTypeOf>>().toEqualTypeOf>() - expectTypeOf>>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf<{ a: number; b: string }>() + expectTypeOf>>().toEqualTypeOf< + AsyncIteratorClass + >() + expectTypeOf>>().toEqualTypeOf< + AsyncGenerator + >() + expectTypeOf>>().toEqualTypeOf< + AsyncIteratorObject + >() expectTypeOf>().toEqualTypeOf() }) it('complex', () => { expectTypeOf< - JsonifiedValue> - >().toEqualTypeOf< - { a: number, b: string, c: [string, 1, 2, 3, ...string[]], g: unknown }[] - >() + JsonifiedValue> + >().toEqualTypeOf<{ a: number; b: string; c: [string, 1, 2, 3, ...string[]]; g: unknown }[]>() }) it('interface', () => { - interface User { id: number, createdAt: Date, tags?: Set } - interface Callable { (): void, a: number } + interface User { + id: number + createdAt: Date + tags?: Set + } + interface Callable { + (): void + a: number + } - expectTypeOf>().toEqualTypeOf<{ id: number, createdAt: string, tags?: string[] }>() - expectTypeOf>().toEqualTypeOf<{ - user: { id: number, createdAt: string, tags?: string[] } - users: { id: number, createdAt: string, tags?: string[] }[] + expectTypeOf>().toEqualTypeOf<{ + id: number + createdAt: string + tags?: string[] + }>() + expectTypeOf>().toEqualTypeOf<{ + user: { id: number; createdAt: string; tags?: string[] } + users: { id: number; createdAt: string; tags?: string[] }[] }>() expectTypeOf>().toEqualTypeOf() }) @@ -66,21 +89,33 @@ describe('JsonifiedValue', () => { describe('JsonifiedClient', () => { it('leaf-level', () => { - expectTypeOf }, Error | ORPCError> - >>().toEqualTypeOf< - Client<{ cache?: boolean }, { now: Date }, { b: string[] }, Error | ORPCError> + expectTypeOf< + JsonifiedClient< + Client< + { cache?: boolean }, + { now: Date }, + { b: Set }, + Error | ORPCError + > + > + >().toEqualTypeOf< + Client< + { cache?: boolean }, + { now: Date }, + { b: string[] }, + Error | ORPCError + > >() }) it('interface output', () => { - interface Output { now: Date } + interface Output { + now: Date + } - expectTypeOf - >>().toEqualTypeOf< - Client<{ cache?: boolean }, { now: Date }, { now: string }, Error> - >() + expectTypeOf< + JsonifiedClient> + >().toEqualTypeOf>() }) it('preserves event iterator yield/return types', () => { @@ -92,15 +127,37 @@ describe('JsonifiedClient', () => { }) it('nested', () => { - expectTypeOf }, Error | ORPCError> - planet: { - find: Client<{ cache?: boolean }, { now: Date }, { b: Set }, Error | ORPCError> - } - }>>().toEqualTypeOf<{ - ping: Client<{ cache?: boolean }, { now: Date }, { b: string[] }, Error | ORPCError> + expectTypeOf< + JsonifiedClient<{ + ping: Client< + { cache?: boolean }, + { now: Date }, + { b: Set }, + Error | ORPCError + > + planet: { + find: Client< + { cache?: boolean }, + { now: Date }, + { b: Set }, + Error | ORPCError + > + } + }> + >().toEqualTypeOf<{ + ping: Client< + { cache?: boolean }, + { now: Date }, + { b: string[] }, + Error | ORPCError + > planet: { - find: Client<{ cache?: boolean }, { now: Date }, { b: string[] }, Error | ORPCError> + find: Client< + { cache?: boolean }, + { now: Date }, + { b: string[] }, + Error | ORPCError + > } }>() }) diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index 19b88a910d..4c08f269a9 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -1,8 +1,8 @@ -// eslint-disable-next-line no-restricted-imports +// oxlint-disable-next-line no-restricted-imports import type * as OpenAPIV3_0 from '@openapi-spec/types/v3.0' -// eslint-disable-next-line no-restricted-imports +// oxlint-disable-next-line no-restricted-imports import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' -// eslint-disable-next-line no-restricted-imports +// oxlint-disable-next-line no-restricted-imports import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' import type { AnyNestedClient, Client, ORPCError } from '@orpc/client' import type { AsyncIteratorClass } from '@orpc/shared' @@ -21,27 +21,43 @@ export type OpenAPIVersion = `3.0.${number}` | `3.1.${number}` | `3.2.${number}` * * @see {@link https://orpc.dev/docs/openapi/specification#openapi-version | OpenAPI Specification - OpenAPI Version} */ -export type OpenAPIDocument - = TVersion extends `3.0.${string}` ? OpenAPIV3_0.OpenAPIObject - : TVersion extends `3.1.${string}` ? OpenAPIV3_1.OpenAPIObject - : OpenAPIV3_2.OpenAPIObject +export type OpenAPIDocument = TVersion extends `3.0.${string}` + ? OpenAPIV3_0.OpenAPIObject + : TVersion extends `3.1.${string}` + ? OpenAPIV3_1.OpenAPIObject + : OpenAPIV3_2.OpenAPIObject -export type JsonifiedValue - = T extends string | number | boolean | null | undefined ? T - : T extends Date | bigint | URL ? string - : T extends ReadonlyArray ? JsonifiedArray - // one non-inferring check so plain objects skip the costly `infer` branches below - : T extends Blob | ReadonlyMap | ReadonlySet | AsyncIteratorObject | Function // eslint-disable-line ts/no-unsafe-function-type - ? T extends File ? File - : T extends Blob ? Blob - : T extends ReadonlyMap ? JsonifiedArray<[K, V][]> - : T extends ReadonlySet ? JsonifiedArray - : T extends AsyncIteratorClass ? AsyncIteratorClass, JsonifiedValue> - : T extends AsyncGenerator ? AsyncGenerator, JsonifiedValue> - : T extends AsyncIteratorObject ? AsyncIteratorObject, JsonifiedValue> - : unknown - : T extends object ? { [K in keyof T]: JsonifiedValue } - : unknown +export type JsonifiedValue = T extends string | number | boolean | null | undefined + ? T + : T extends Date | bigint | URL + ? string + : T extends ReadonlyArray + ? JsonifiedArray + : // one non-inferring check so plain objects skip the costly `infer` branches below + T extends + | Blob + | ReadonlyMap + | ReadonlySet + | AsyncIteratorObject + | Function + ? T extends File + ? File + : T extends Blob + ? Blob + : T extends ReadonlyMap + ? JsonifiedArray<[K, V][]> + : T extends ReadonlySet + ? JsonifiedArray + : T extends AsyncIteratorClass + ? AsyncIteratorClass, JsonifiedValue> + : T extends AsyncGenerator + ? AsyncGenerator, JsonifiedValue> + : T extends AsyncIteratorObject + ? AsyncIteratorObject, JsonifiedValue> + : unknown + : T extends object + ? { [K in keyof T]: JsonifiedValue } + : unknown export type JsonifiedArray> = T extends readonly [] ? [] @@ -51,19 +67,17 @@ export type JsonifiedArray> = T extends readonl ? Array> : unknown -export type JsonifiedClientError - = T extends ORPCError - ? ORPCError> - : T +export type JsonifiedClientError = + T extends ORPCError ? ORPCError> : T /** * Client type whose outputs and error data replace types JSON cannot represent with their JSON equivalents. * * @see {@link https://orpc.dev/docs/openapi/link | OpenAPI Link} */ -export type JsonifiedClient - = T extends Client +export type JsonifiedClient = + T extends Client ? Client, JsonifiedClientError> : { - [K in keyof T]: T[K] extends AnyNestedClient ? JsonifiedClient : T[K]; + [K in keyof T]: T[K] extends AnyNestedClient ? JsonifiedClient : T[K] } diff --git a/packages/openapi/src/utils.test.ts b/packages/openapi/src/utils.test.ts index 44117be465..cb6cfbfe6d 100644 --- a/packages/openapi/src/utils.test.ts +++ b/packages/openapi/src/utils.test.ts @@ -1,4 +1,4 @@ -import { getDynamicPathParams, isBodylessMethod } from './utils' +import { getDynamicPathParams, isBodylessMethod } from './utils.ts' describe('isBodylessMethod', () => { it('treats GET and HEAD as bodyless', () => { diff --git a/packages/openapi/src/utils.ts b/packages/openapi/src/utils.ts index f782857420..59054c6aae 100644 --- a/packages/openapi/src/utils.ts +++ b/packages/openapi/src/utils.ts @@ -11,23 +11,27 @@ const PARAMETER_NAME_REGEX = /^[\w-]+$/ /** * Dynamic parameters are always returned in path order. */ -export function getDynamicPathParams(path: `/${string}`): { - segment: string - startIndex: number - parameterName: string - allowsSlash: boolean -}[] | undefined { +export function getDynamicPathParams(path: `/${string}`): + | { + segment: string + startIndex: number + parameterName: string + allowsSlash: boolean + }[] + | undefined { if (!path.includes('{')) { return undefined } const len = path.length - let params: { - segment: string - startIndex: number - parameterName: string - allowsSlash: boolean - }[] | undefined + let params: + | { + segment: string + startIndex: number + parameterName: string + allowsSlash: boolean + }[] + | undefined let index = 1 @@ -36,7 +40,11 @@ export function getDynamicPathParams(path: `/${string}`): { const slashPos = path.indexOf('/', index) const segmentEnd = slashPos === -1 ? len : slashPos - if (segmentEnd > segmentStart && path.charCodeAt(segmentStart) === 123 && path.charCodeAt(segmentEnd - 1) === 125) { + if ( + segmentEnd > segmentStart && + path.charCodeAt(segmentStart) === 123 && + path.charCodeAt(segmentEnd - 1) === 125 + ) { let parameterStart = segmentStart + 1 let allowsSlash = false diff --git a/packages/openapi/tests/__shared__/schema.ts b/packages/openapi/tests/__shared__/schema.ts index e3db42b0f6..2abbc5ef58 100644 --- a/packages/openapi/tests/__shared__/schema.ts +++ b/packages/openapi/tests/__shared__/schema.ts @@ -1,6 +1,6 @@ import type { AnySchema } from '@orpc/contract' import type { JsonSchema, JsonSchemaConverter } from '@orpc/json-schema' -import z from 'zod' +import * as z from 'zod' /** * Converts zod schemas during tests. @@ -9,7 +9,7 @@ import z from 'zod' * (see {@link testSchema}) work the same way. */ export const zodJsonSchemaConverter: JsonSchemaConverter = { - condition: schema => schema?.['~standard'].vendor === 'zod', + condition: (schema) => schema?.['~standard'].vendor === 'zod', convert(schema, direction) { const jsonSchema = z.toJSONSchema(schema as any, { io: direction }) const output = schema?.['~standard'].validate(undefined) @@ -48,7 +48,7 @@ export function testSchema(jsonSchema: JsonSchema, options: TestSchemaOptions = } export const testSchemaConverter: JsonSchemaConverter = { - condition: schema => (schema as any)?.['~standard'].vendor === 'orpc-test', + condition: (schema) => (schema as any)?.['~standard'].vendor === 'orpc-test', convert(schema, direction) { const detail = (schema as any)['~test'] return [detail[direction], detail.optional] diff --git a/packages/openapi/tests/openapi-generator/composed-schemas.test.ts b/packages/openapi/tests/openapi-generator/composed-schemas.test.ts index e19a24d667..e4d2b00120 100644 --- a/packages/openapi/tests/openapi-generator/composed-schemas.test.ts +++ b/packages/openapi/tests/openapi-generator/composed-schemas.test.ts @@ -1,7 +1,8 @@ import { oc } from '@orpc/contract' -import z from 'zod' -import { openapi, OpenAPIGenerator } from '../../src' -import { zodJsonSchemaConverter } from '../__shared__/schema' +import * as z from 'zod' + +import { openapi, OpenAPIGenerator } from '../../src/index.ts' +import { zodJsonSchemaConverter } from '../__shared__/schema.ts' describe('openAPIGenerator e2e: composed schemas', () => { const generator = new OpenAPIGenerator({ converters: [zodJsonSchemaConverter] }) @@ -10,92 +11,104 @@ describe('openAPIGenerator e2e: composed schemas', () => { const doc = await generator.generate({ ingestEvent: oc .meta(openapi({ method: 'POST', path: '/events/{type}' })) - .input(z.discriminatedUnion('type', [ - z.object({ type: z.literal('click'), x: z.number() }), - z.object({ type: z.literal('view'), duration: z.number() }), - ])) - .output(z.discriminatedUnion('type', [ - z.object({ type: z.literal('click'), x: z.number() }), - z.object({ type: z.literal('view'), duration: z.number() }), - ])), + .input( + z.discriminatedUnion('type', [ + z.object({ type: z.literal('click'), x: z.number() }), + z.object({ type: z.literal('view'), duration: z.number() }), + ]), + ) + .output( + z.discriminatedUnion('type', [ + z.object({ type: z.literal('click'), x: z.number() }), + z.object({ type: z.literal('view'), duration: z.number() }), + ]), + ), }) - expect(doc.paths?.['/events/{type}']?.post).toEqual(expect.objectContaining({ - parameters: [ - { - name: 'type', - in: 'path', - required: true, - schema: { - anyOf: [ - { const: 'click', type: 'string' }, - { const: 'view', type: 'string' }, - ], - }, - }, - ], - requestBody: { - content: { - 'application/json': { - schema: expect.objectContaining({ - type: 'object', - properties: { - x: { type: 'number' }, - duration: { type: 'number' }, - }, - }), + expect(doc.paths?.['/events/{type}']?.post).toEqual( + expect.objectContaining({ + parameters: [ + { + name: 'type', + in: 'path', + required: true, + schema: { + anyOf: [ + { const: 'click', type: 'string' }, + { const: 'view', type: 'string' }, + ], + }, }, - }, - }, - responses: { - 200: expect.objectContaining({ + ], + requestBody: { content: { 'application/json': { - schema: { - oneOf: [ - expect.objectContaining({ required: ['type', 'x'] }), - expect.objectContaining({ required: ['type', 'duration'] }), - ], - }, + schema: expect.objectContaining({ + type: 'object', + properties: { + x: { type: 'number' }, + duration: { type: 'number' }, + }, + }), }, }, - }), - }, - })) + }, + responses: { + 200: expect.objectContaining({ + content: { + 'application/json': { + schema: { + oneOf: [ + expect.objectContaining({ required: ['type', 'x'] }), + expect.objectContaining({ required: ['type', 'duration'] }), + ], + }, + }, + }, + }), + }, + }), + ) }) it('documents intersection inputs as query parameters and intersection outputs as a merged object', async () => { const doc = await generator.generate({ searchPlanets: oc .meta(openapi({ method: 'GET', path: '/planets' })) - .input(z.intersection( - z.looseObject({ search: z.string() }), - z.looseObject({ page: z.number() }), - )) - .output(z.intersection( - z.looseObject({ items: z.array(z.string()) }), - z.looseObject({ total: z.number() }), - )), + .input( + z.intersection( + z.looseObject({ search: z.string() }), + z.looseObject({ page: z.number() }), + ), + ) + .output( + z.intersection( + z.looseObject({ items: z.array(z.string()) }), + z.looseObject({ total: z.number() }), + ), + ), }) - expect(doc.paths?.['/planets']?.get).toEqual(expect.objectContaining({ - parameters: [ - expect.objectContaining({ name: 'search', in: 'query', required: true }), - expect.objectContaining({ name: 'page', in: 'query', required: true }), - ], - responses: { - 200: expect.objectContaining({ - content: { - 'application/json': { - schema: expect.objectContaining({ - type: 'object', - required: ['items', 'total'], - }), + expect(doc.paths?.['/planets']?.get).toEqual( + expect.objectContaining({ + parameters: [ + expect.objectContaining({ name: 'search', in: 'query', required: true }), + expect.objectContaining({ name: 'page', in: 'query', required: true }), + ], + responses: { + 200: expect.objectContaining({ + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + required: ['items', 'total'], + }), + }, }, - }, - }), - }, - })) + }), + }, + }), + ) }) it('merges inputs added by multiple .input() calls before extracting params and query', async () => { @@ -107,7 +120,12 @@ describe('openAPIGenerator e2e: composed schemas', () => { }) expect(doc.paths?.['/systems/{systemId}/planets']?.get?.parameters).toEqual([ - { name: 'systemId', in: 'path', required: true, schema: expect.objectContaining({ type: 'string' }) }, + { + name: 'systemId', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, { name: 'search', in: 'query', @@ -121,60 +139,72 @@ describe('openAPIGenerator e2e: composed schemas', () => { it('merges detailed sections added by multiple .input() and .output() calls', 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() }), - })) - .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() }), - })), + .meta( + openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + outputStructure: 'detailed', + }), + ) + .input( + z.looseObject({ + params: z.object({ id: z.string() }), + }), + ) + .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}']?.post).toEqual(expect.objectContaining({ - parameters: [ - expect.objectContaining({ name: 'id', in: 'path' }), - expect.objectContaining({ name: 'x-trace-id', in: 'header' }), - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: expect.objectContaining({ - properties: { name: { type: 'string' } }, - }), - }, - }, - }, - responses: { - 200: { - description: 'OK', - headers: { - 'x-request-id': { - required: true, - schema: { type: 'string' }, - }, - }, + expect(doc.paths?.['/planets/{id}']?.post).toEqual( + expect.objectContaining({ + parameters: [ + expect.objectContaining({ name: 'id', in: 'path' }), + expect.objectContaining({ name: 'x-trace-id', in: 'header' }), + ], + requestBody: { + required: true, content: { 'application/json': { schema: expect.objectContaining({ - properties: { updated: { type: 'boolean' } }, + properties: { name: { type: 'string' } }, }), }, }, }, - }, - })) + responses: { + 200: { + description: 'OK', + headers: { + 'x-request-id': { + required: true, + schema: { type: 'string' }, + }, + }, + content: { + 'application/json': { + schema: expect.objectContaining({ + properties: { updated: { type: 'boolean' } }, + }), + }, + }, + }, + }, + }), + ) }) }) diff --git a/packages/openapi/tests/openapi-generator/crud.test.ts b/packages/openapi/tests/openapi-generator/crud.test.ts index 079f7b9634..9547191e27 100644 --- a/packages/openapi/tests/openapi-generator/crud.test.ts +++ b/packages/openapi/tests/openapi-generator/crud.test.ts @@ -1,69 +1,88 @@ import { oc } from '@orpc/contract' -import z from 'zod' -import { openapi, OpenAPIGenerator } from '../../src' -import { zodJsonSchemaConverter } from '../__shared__/schema' +import * as z from 'zod' + +import { openapi, OpenAPIGenerator } from '../../src/index.ts' +import { zodJsonSchemaConverter } from '../__shared__/schema.ts' describe('openAPIGenerator e2e: crud api', () => { const generator = new OpenAPIGenerator({ converters: [zodJsonSchemaConverter] }) - const Planet = z.object({ - id: z.string(), - name: z.string(), - description: z.string().optional(), - }).meta({ id: 'Planet' }) + const Planet = z + .object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + }) + .meta({ id: 'Planet' }) const router = { planet: { list: oc - .meta(openapi({ - method: 'GET', - prefix: '/api/v1', - path: '/planets', - tags: ['planets'], - summary: 'List planets', - queryStyles: { tags: 'comma-delimited-array' }, - })) - .input(z.object({ - search: z.string().optional(), - tags: z.array(z.string()).optional(), - page: z.number().optional(), - })) + .meta( + openapi({ + method: 'GET', + prefix: '/api/v1', + path: '/planets', + tags: ['planets'], + summary: 'List planets', + queryStyles: { tags: 'comma-delimited-array' }, + }), + ) + .input( + z.object({ + search: z.string().optional(), + tags: z.array(z.string()).optional(), + page: z.number().optional(), + }), + ) .output(z.array(Planet)), find: oc - .meta(openapi({ method: 'GET', prefix: '/api/v1', path: '/planets/{id}', tags: ['planets'] })) + .meta( + openapi({ method: 'GET', prefix: '/api/v1', path: '/planets/{id}', tags: ['planets'] }), + ) .input(z.object({ id: z.string() })) .output(Planet), create: oc - .meta(openapi({ - method: 'POST', - prefix: '/api/v1', - path: '/planets', - tags: ['planets'], - successStatus: 201, - successDescription: 'Planet created', - })) + .meta( + openapi({ + method: 'POST', + prefix: '/api/v1', + path: '/planets', + tags: ['planets'], + successStatus: 201, + successDescription: 'Planet created', + }), + ) .input(z.object({ name: z.string(), description: z.string().optional() })) .output(Planet), update: oc - .meta(openapi({ method: 'PUT', prefix: '/api/v1', path: '/planets/{id}', tags: ['planets'] })) - .input(z.object({ - id: z.string(), - name: z.string().optional(), - description: z.string().optional(), - })) + .meta( + openapi({ method: 'PUT', prefix: '/api/v1', path: '/planets/{id}', tags: ['planets'] }), + ) + .input( + z.object({ + id: z.string(), + name: z.string().optional(), + description: z.string().optional(), + }), + ) .output(Planet), compare: oc - .meta(openapi({ - method: 'GET', - prefix: '/api/v1', - path: '/planets/{ids}/compare', - tags: ['planets'], - paramsStyles: { ids: 'comma-delimited-array' }, - })) + .meta( + openapi({ + method: 'GET', + prefix: '/api/v1', + path: '/planets/{ids}/compare', + tags: ['planets'], + paramsStyles: { ids: 'comma-delimited-array' }, + }), + ) .input(z.object({ ids: z.array(z.string()) })) .output(z.array(Planet)), exists: oc - .meta(openapi({ method: 'HEAD', prefix: '/api/v1', path: '/planets/{id}', tags: ['planets'] })) + .meta( + openapi({ method: 'HEAD', prefix: '/api/v1', path: '/planets/{id}', tags: ['planets'] }), + ) .input(z.object({ id: z.string(), includeArchived: z.boolean().optional() })), }, } @@ -76,7 +95,11 @@ describe('openAPIGenerator e2e: crud api', () => { expect(doc.info).toEqual({ title: 'Planet API', version: '1.0.0' }) expect(doc.paths).toEqual({ '/api/v1/planets': { - get: expect.objectContaining({ operationId: 'planet.list', summary: 'List planets', tags: ['planets'] }), + get: expect.objectContaining({ + operationId: 'planet.list', + summary: 'List planets', + tags: ['planets'], + }), post: expect.objectContaining({ operationId: 'planet.create' }), }, '/api/v1/planets/{id}': { @@ -93,18 +116,20 @@ describe('openAPIGenerator e2e: crud api', () => { it('maps HEAD inputs to query parameters like GET, without a request body', async () => { const doc = await generator.generate(router) - expect(doc.paths?.['/api/v1/planets/{id}']?.head).toEqual(expect.objectContaining({ - parameters: [ - { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, - { - name: 'includeArchived', - in: 'query', - allowEmptyValue: true, - allowReserved: true, - schema: { type: 'boolean' }, - }, - ], - })) + expect(doc.paths?.['/api/v1/planets/{id}']?.head).toEqual( + expect.objectContaining({ + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { + name: 'includeArchived', + in: 'query', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'boolean' }, + }, + ], + }), + ) expect((doc.paths?.['/api/v1/planets/{id}']?.head as any).requestBody).toBeUndefined() }) @@ -155,89 +180,100 @@ describe('openAPIGenerator e2e: crud api', () => { it('maps path params and reuses the shared Planet component in responses', async () => { const doc = await generator.generate(router) - expect(doc.paths?.['/api/v1/planets/{id}']?.get).toEqual(expect.objectContaining({ - parameters: [ - { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, - ], - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/Planet' }, + expect(doc.paths?.['/api/v1/planets/{id}']?.get).toEqual( + expect.objectContaining({ + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Planet' }, + }, }, }, }, - }, - })) + }), + ) // the list response references the same hoisted Planet component - expect((doc.paths?.['/api/v1/planets']?.get?.responses?.['200'] as any).content['application/json'].schema).toEqual({ + expect( + (doc.paths?.['/api/v1/planets']?.get?.responses?.['200'] as any).content['application/json'] + .schema, + ).toEqual({ type: 'array', items: { $ref: '#/components/schemas/Planet' }, }) - expect(doc.components?.schemas?.Planet).toEqual(expect.objectContaining({ - type: 'object', - required: ['id', 'name'], - })) + expect(doc.components?.schemas?.Planet).toEqual( + expect.objectContaining({ + type: 'object', + required: ['id', 'name'], + }), + ) }) it('maps create inputs to a required request body with a custom success status', async () => { const doc = await generator.generate(router) - expect(doc.paths?.['/api/v1/planets']?.post).toEqual(expect.objectContaining({ - requestBody: { - required: true, - content: { - 'application/json': { - schema: expect.objectContaining({ - type: 'object', - required: ['name'], - }), + expect(doc.paths?.['/api/v1/planets']?.post).toEqual( + expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + required: ['name'], + }), + }, }, }, - }, - responses: { - 201: expect.objectContaining({ description: 'Planet created' }), - }, - })) + responses: { + 201: expect.objectContaining({ description: 'Planet created' }), + }, + }), + ) }) it('maps update inputs to a path param plus an optional request body', async () => { const doc = await generator.generate(router) - expect(doc.paths?.['/api/v1/planets/{id}']?.put).toEqual(expect.objectContaining({ - parameters: [ - { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, - ], - requestBody: { - content: { - 'application/json': { - schema: expect.objectContaining({ - type: 'object', - properties: expect.objectContaining({ - name: { type: 'string' }, - description: { type: 'string' }, + expect(doc.paths?.['/api/v1/planets/{id}']?.put).toEqual( + expect.objectContaining({ + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ + name: { type: 'string' }, + description: { type: 'string' }, + }), }), - }), + }, }, }, - }, - })) + }), + ) }) it('rejects invalid contracts with one aggregated error listing every procedure', async () => { - const error = await generator.generate({ - // path params must be required - find: oc - .meta(openapi({ method: 'GET', path: '/planets/{id}' })) - .input(z.object({ id: z.string().optional() })), - // GET inputs must be objects - list: oc.meta(openapi({ method: 'GET' })).input(z.string()), - }).then( - () => { throw new Error('expected generate to reject') }, - e => e, - ) + const error = await generator + .generate({ + // path params must be required + find: oc + .meta(openapi({ method: 'GET', path: '/planets/{id}' })) + .input(z.object({ id: z.string().optional() })), + // GET inputs must be objects + list: oc.meta(openapi({ method: 'GET' })).input(z.string()), + }) + .then( + () => { + throw new Error('expected generate to reject') + }, + (e) => e, + ) expect(error.message).toContain('Procedure at find:') expect(error.message).toContain('is optional in the input schema') diff --git a/packages/openapi/tests/openapi-generator/detailed-http.test.ts b/packages/openapi/tests/openapi-generator/detailed-http.test.ts index 29151c40d9..6cedd0d711 100644 --- a/packages/openapi/tests/openapi-generator/detailed-http.test.ts +++ b/packages/openapi/tests/openapi-generator/detailed-http.test.ts @@ -1,72 +1,81 @@ import { oc } from '@orpc/contract' -import z from 'zod' -import { openapi, OpenAPIGenerator } from '../../src' -import { zodJsonSchemaConverter } from '../__shared__/schema' +import * as z from 'zod' + +import { openapi, OpenAPIGenerator } from '../../src/index.ts' +import { zodJsonSchemaConverter } from '../__shared__/schema.ts' describe('openAPIGenerator e2e: webhook endpoint with full http control', () => { const generator = new OpenAPIGenerator({ converters: [zodJsonSchemaConverter] }) const router = { receiveEvent: oc - .meta(openapi({ - method: 'POST', - path: '/webhooks/{source}', - inputStructure: 'detailed', - outputStructure: 'detailed', - })) - .input(z.object({ - params: z.object({ source: z.string() }), - query: z.object({ retry: z.boolean().optional() }), - headers: z.object({ - 'x-signature': z.string(), - 'x-idempotency-key': z.string().optional(), - }), - body: z.object({ event: z.string(), payload: z.unknown() }), - })) - .output(z.union([ - z.object({ - status: z.literal(200).describe('Processed immediately'), - body: z.object({ processed: z.boolean() }), + .meta( + openapi({ + method: 'POST', + path: '/webhooks/{source}', + inputStructure: 'detailed', + outputStructure: 'detailed', }), + ) + .input( z.object({ - status: z.literal(202).describe('Queued for processing'), - headers: z.object({ 'x-queue-id': z.string() }), - body: z.object({ queued: z.boolean() }), + params: z.object({ source: z.string() }), + query: z.object({ retry: z.boolean().optional() }), + headers: z.object({ + 'x-signature': z.string(), + 'x-idempotency-key': z.string().optional(), + }), + body: z.object({ event: z.string(), payload: z.unknown() }), }), - ])), + ) + .output( + z.union([ + z.object({ + status: z.literal(200).describe('Processed immediately'), + body: z.object({ processed: z.boolean() }), + }), + z.object({ + status: z.literal(202).describe('Queued for processing'), + headers: z.object({ 'x-queue-id': z.string() }), + body: z.object({ queued: z.boolean() }), + }), + ]), + ), } it('maps params, query, and headers to parameters and the body section to the request body', async () => { const doc = await generator.generate(router) - expect(doc.paths?.['/webhooks/{source}']?.post).toEqual(expect.objectContaining({ - parameters: [ - { name: 'source', in: 'path', required: true, schema: { type: 'string' } }, - { - name: 'retry', - in: 'query', - allowEmptyValue: true, - allowReserved: true, - schema: { type: 'boolean' }, - }, - { name: 'x-signature', in: 'header', required: true, schema: { type: 'string' } }, - { name: 'x-idempotency-key', in: 'header', schema: { type: 'string' } }, - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: expect.objectContaining({ - type: 'object', - properties: expect.objectContaining({ - event: { type: 'string' }, + expect(doc.paths?.['/webhooks/{source}']?.post).toEqual( + expect.objectContaining({ + parameters: [ + { name: 'source', in: 'path', required: true, schema: { type: 'string' } }, + { + name: 'retry', + in: 'query', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'boolean' }, + }, + { name: 'x-signature', in: 'header', required: true, schema: { type: 'string' } }, + { name: 'x-idempotency-key', in: 'header', schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ + event: { type: 'string' }, + }), + required: ['event', 'payload'], }), - required: ['event', 'payload'], - }), + }, }, }, - }, - })) + }), + ) }) it('maps each detailed output status to its own response with headers and description', async () => { @@ -113,14 +122,18 @@ describe('openAPIGenerator e2e: webhook endpoint with full http control', () => it('uses successStatus and successDescription when detailed outputs omit the status field', async () => { const doc = await generator.generate({ acknowledge: oc - .meta(openapi({ - outputStructure: 'detailed', - successStatus: 226, - successDescription: 'IM Used', - })) - .output(z.object({ - body: z.object({ ok: z.boolean() }), - })), + .meta( + openapi({ + outputStructure: 'detailed', + successStatus: 226, + successDescription: 'IM Used', + }), + ) + .output( + z.object({ + body: z.object({ ok: z.boolean() }), + }), + ), }) expect(doc.paths?.['/acknowledge']?.post?.responses).toEqual({ diff --git a/packages/openapi/tests/openapi-generator/event-streaming.test.ts b/packages/openapi/tests/openapi-generator/event-streaming.test.ts index b0b20c413a..f8ac9327e5 100644 --- a/packages/openapi/tests/openapi-generator/event-streaming.test.ts +++ b/packages/openapi/tests/openapi-generator/event-streaming.test.ts @@ -1,7 +1,8 @@ import { asyncIteratorObject, oc } from '@orpc/contract' -import z from 'zod' -import { openapi, OpenAPIGenerator } from '../../src' -import { zodJsonSchemaConverter } from '../__shared__/schema' +import * as z from 'zod' + +import { openapi, OpenAPIGenerator } from '../../src/index.ts' +import { zodJsonSchemaConverter } from '../__shared__/schema.ts' describe('openAPIGenerator e2e: server-sent event streaming', () => { const generator = new OpenAPIGenerator({ converters: [zodJsonSchemaConverter] }) @@ -10,8 +11,12 @@ describe('openAPIGenerator e2e: server-sent event streaming', () => { const doc = await generator.generate({ chat: oc .meta(openapi({ path: '/chat' })) - .input(asyncIteratorObject(z.object({ prompt: z.string() }), z.object({ done: z.boolean() }))) - .output(asyncIteratorObject(z.object({ delta: z.string() }), z.object({ usage: z.number() }))), + .input( + asyncIteratorObject(z.object({ prompt: z.string() }), z.object({ done: z.boolean() })), + ) + .output( + asyncIteratorObject(z.object({ delta: z.string() }), z.object({ usage: z.number() })), + ), }) expect(doc.paths?.['/chat']?.post?.requestBody).toEqual({ @@ -91,7 +96,9 @@ describe('openAPIGenerator e2e: server-sent event streaming', () => { .output(asyncIteratorObject(z.object({ title: z.string() }))), }) - const schema = (doc.paths?.['/notifications']?.get?.responses?.['200'] as any).content['text/event-stream'].schema + const schema = (doc.paths?.['/notifications']?.get?.responses?.['200'] as any).content[ + 'text/event-stream' + ].schema expect(schema.oneOf[1]).toEqual({ type: 'object', diff --git a/packages/openapi/tests/openapi-generator/file-transfer.test.ts b/packages/openapi/tests/openapi-generator/file-transfer.test.ts index dcca8c48ec..6cc5fdc209 100644 --- a/packages/openapi/tests/openapi-generator/file-transfer.test.ts +++ b/packages/openapi/tests/openapi-generator/file-transfer.test.ts @@ -1,7 +1,8 @@ import { oc } from '@orpc/contract' -import z from 'zod' -import { openapi, OpenAPIGenerator } from '../../src' -import { zodJsonSchemaConverter } from '../__shared__/schema' +import * as z from 'zod' + +import { openapi, OpenAPIGenerator } from '../../src/index.ts' +import { zodJsonSchemaConverter } from '../__shared__/schema.ts' describe('openAPIGenerator e2e: file upload and download', () => { const generator = new OpenAPIGenerator({ converters: [zodJsonSchemaConverter] }) @@ -28,12 +29,12 @@ describe('openAPIGenerator e2e: file upload and download', () => { it('documents form uploads with nested files as multipart/form-data', async () => { const doc = await generator.generate({ - uploadAvatar: oc - .meta(openapi({ method: 'POST', path: '/avatars' })) - .input(z.object({ + uploadAvatar: oc.meta(openapi({ method: 'POST', path: '/avatars' })).input( + z.object({ userId: z.string(), image: z.file().mime(['image/png', 'image/jpeg']), - })), + }), + ), }) expect(doc.paths?.['/avatars']?.post?.requestBody).toEqual({ @@ -72,10 +73,7 @@ describe('openAPIGenerator e2e: file upload and download', () => { exportPlanets: oc .meta(openapi({ method: 'GET', path: '/planets/export' })) .input(z.object({ format: z.string().optional() })) - .output(z.union([ - z.file().mime('text/csv'), - z.array(z.object({ id: z.string() })), - ])), + .output(z.union([z.file().mime('text/csv'), z.array(z.object({ id: z.string() }))])), }) expect(doc.paths?.['/planets/export']?.get?.responses?.['200']).toEqual({ @@ -95,10 +93,12 @@ describe('openAPIGenerator e2e: file upload and download', () => { const doc = await generator.generate({ importPlanets: oc .meta(openapi({ method: 'POST', path: '/planets/import' })) - .input(z.union([ - z.file().mime('application/json'), - z.object({ planets: z.array(z.object({ id: z.string() })) }), - ])), + .input( + z.union([ + z.file().mime('application/json'), + z.object({ planets: z.array(z.object({ id: z.string() })) }), + ]), + ), }) expect(doc.paths?.['/planets/import']?.post?.requestBody).toEqual({ diff --git a/packages/openapi/tests/openapi-generator/reusable-components.test.ts b/packages/openapi/tests/openapi-generator/reusable-components.test.ts index 535b374aa6..caab393df7 100644 --- a/packages/openapi/tests/openapi-generator/reusable-components.test.ts +++ b/packages/openapi/tests/openapi-generator/reusable-components.test.ts @@ -1,16 +1,21 @@ import { eventIterator, oc } from '@orpc/contract' -import z from 'zod' -import { openapi, OpenAPIGenerator } from '../../src' -import { zodJsonSchemaConverter } from '../__shared__/schema' +import * as z from 'zod' + +import { openapi, OpenAPIGenerator } from '../../src/index.ts' +import { zodJsonSchemaConverter } from '../__shared__/schema.ts' describe('openAPIGenerator e2e: reusable component schemas', () => { const generator = new OpenAPIGenerator({ converters: [zodJsonSchemaConverter] }) it('hoists a recursive entity into a single component referenced everywhere', async () => { - const Category: z.ZodTypeAny = z.lazy(() => z.looseObject({ - name: z.string(), - children: z.array(Category).optional(), - })).meta({ id: 'Category' }) + const Category: z.ZodTypeAny = z + .lazy(() => + z.looseObject({ + name: z.string(), + children: z.array(Category).optional(), + }), + ) + .meta({ id: 'Category' }) const doc = await generator.generate({ getCategory: oc @@ -23,14 +28,20 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { .output(z.object({ category: Category })), }) - expect((doc.paths?.['/categories/{name}']?.get?.responses?.['200'] as any).content['application/json'].schema).toEqual( + expect( + (doc.paths?.['/categories/{name}']?.get?.responses?.['200'] as any).content[ + 'application/json' + ].schema, + ).toEqual( expect.objectContaining({ properties: { category: { $ref: '#/components/schemas/Category' }, }, }), ) - expect((doc.paths?.['/categories']?.post?.requestBody as any).content['application/json'].schema).toEqual( + expect( + (doc.paths?.['/categories']?.post?.requestBody as any).content['application/json'].schema, + ).toEqual( expect.objectContaining({ properties: { category: { $ref: '#/components/schemas/Category' }, @@ -52,16 +63,19 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { }) it('hoists mutually recursive entities that reference each other', async () => { - const User: z._ZodType = z.looseObject({ - name: z.string(), - // eslint-disable-next-line ts/no-use-before-define - posts: z.array(z.lazy(() => Post)).optional(), - }).meta({ id: 'User' }) + const User: z._ZodType = z + .looseObject({ + name: z.string(), + posts: z.array(z.lazy(() => Post)).optional(), + }) + .meta({ id: 'User' }) - const Post: z.ZodTypeAny = z.looseObject({ - title: z.string(), - author: z.lazy(() => User).optional(), - }).meta({ id: 'Post' }) + const Post: z.ZodTypeAny = z + .looseObject({ + title: z.string(), + author: z.lazy(() => User).optional(), + }) + .meta({ id: 'Post' }) const doc = await generator.generate({ getUser: oc.input(z.object({ user: User })).output(z.object({ user: User })), @@ -88,7 +102,9 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { it('hoists an entity that recurses to its own root out of bodies, error data, and event streams', async () => { const Tree: z.ZodTypeAny = z.looseObject({ name: z.string(), - get children() { return z.array(Tree) }, + get children() { + return z.array(Tree) + }, }) const doc = await generator.generate({ @@ -97,16 +113,20 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { .errors({ BAD_REQUEST: { data: Tree } }) .input(Tree) .output(Tree), - streamTrees: oc - .meta(openapi({ method: 'GET', path: '/trees' })) - .output(eventIterator(Tree)), + streamTrees: oc.meta(openapi({ method: 'GET', path: '/trees' })).output(eventIterator(Tree)), }) const operation = doc.paths?.['/trees']?.post - expect((operation?.requestBody as any).content['application/json'].schema).toEqual({ $ref: '#/components/schemas/__schema0' }) - expect((operation?.responses?.['200'] as any).content['application/json'].schema).toEqual({ $ref: '#/components/schemas/__schema0' }) - expect((doc.paths?.['/trees']?.get?.responses?.['200'] as any).content['text/event-stream'].schema.oneOf[0].properties.data) - .toEqual({ $ref: '#/components/schemas/__schema0' }) + expect((operation?.requestBody as any).content['application/json'].schema).toEqual({ + $ref: '#/components/schemas/__schema0', + }) + expect((operation?.responses?.['200'] as any).content['application/json'].schema).toEqual({ + $ref: '#/components/schemas/__schema0', + }) + expect( + (doc.paths?.['/trees']?.get?.responses?.['200'] as any).content['text/event-stream'].schema + .oneOf[0].properties.data, + ).toEqual({ $ref: '#/components/schemas/__schema0' }) expect(doc.components?.schemas).toEqual({ __schema0: expect.objectContaining({ properties: { @@ -123,7 +143,11 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { it('keeps a root-recursive union whole when its file branch is split out', async () => { const Node: z.ZodTypeAny = z.union([ - z.object({ get children() { return z.array(Node) } }), + z.object({ + get children() { + return z.array(Node) + }, + }), z.file().mime('image/png'), ]) @@ -132,11 +156,19 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { }) const content = (doc.paths?.['/nodes']?.post?.requestBody as any).content - expect(content['multipart/form-data'].schema.properties.children.items).toEqual({ $ref: '#/components/schemas/__schema0' }) - expect(content['image/png'].schema).toEqual(expect.objectContaining({ contentMediaType: 'image/png' })) + expect(content['multipart/form-data'].schema.properties.children.items).toEqual({ + $ref: '#/components/schemas/__schema0', + }) + expect(content['image/png'].schema).toEqual( + expect.objectContaining({ contentMediaType: 'image/png' }), + ) expect(doc.components?.schemas?.__schema0).toEqual({ anyOf: [ - expect.objectContaining({ properties: { children: { type: 'array', items: { $ref: '#/components/schemas/__schema0' } } } }), + expect.objectContaining({ + properties: { + children: { type: 'array', items: { $ref: '#/components/schemas/__schema0' } }, + }, + }), expect.objectContaining({ contentMediaType: 'image/png' }), ], }) @@ -148,24 +180,28 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { const Planet = z.object({ id: z.string() }).meta({ id: 'Planet' }) const doc = await generator.generate({ - createPlanet: oc - .input(z.object({ planet: Planet })) - .output(z.object({ planet: Planet })), - clonePlanet: oc - .input(z.object({ planet: Planet })) - .output(z.object({ planet: Planet })), + createPlanet: oc.input(z.object({ planet: Planet })).output(z.object({ planet: Planet })), + clonePlanet: oc.input(z.object({ planet: Planet })).output(z.object({ planet: Planet })), }) for (const path of ['/createPlanet', '/clonePlanet'] as const) { - expect((doc.paths?.[path]?.post?.requestBody as any).content['application/json'].schema.properties).toEqual({ + expect( + (doc.paths?.[path]?.post?.requestBody as any).content['application/json'].schema.properties, + ).toEqual({ planet: { $ref: '#/components/schemas/Planet' }, }) - expect((doc.paths?.[path]?.post?.responses?.['200'] as any).content['application/json'].schema.properties).toEqual({ + expect( + (doc.paths?.[path]?.post?.responses?.['200'] as any).content['application/json'].schema + .properties, + ).toEqual({ planet: { $ref: '#/components/schemas/PlanetOutput' }, }) } - expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['Planet', 'PlanetOutput']) + expect(Object.keys(doc.components?.schemas ?? {}).toSorted()).toEqual([ + 'Planet', + 'PlanetOutput', + ]) expect(doc.components?.schemas?.Planet).toEqual({ type: 'object', @@ -184,26 +220,31 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { const Planet = z.object({ id: z.string() }).meta({ id: 'Planet' }) const Moon = z.object({ radius: z.number() }).meta({ id: 'Moon' }) - const doc = await generator.generate({ - planet: oc.input(z.object({ planet: Planet, moon: Moon })), - }, { - base: { - components: { - schemas: { - // equal to the generated Planet schema, reused as-is - Planet: { - type: 'object', - properties: { id: { type: 'string' } }, - required: ['id'], - } as any, - // different from the generated Moon schema, forcing a postfix - Moon: { type: 'string' } as any, + const doc = await generator.generate( + { + planet: oc.input(z.object({ planet: Planet, moon: Moon })), + }, + { + base: { + components: { + schemas: { + // equal to the generated Planet schema, reused as-is + Planet: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + } as any, + // different from the generated Moon schema, forcing a postfix + Moon: { type: 'string' } as any, + }, }, }, }, - }) + ) - expect((doc.paths?.['/planet']?.post?.requestBody as any).content['application/json'].schema).toEqual( + expect( + (doc.paths?.['/planet']?.post?.requestBody as any).content['application/json'].schema, + ).toEqual( expect.objectContaining({ properties: { planet: { $ref: '#/components/schemas/Planet' }, @@ -226,13 +267,18 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { it('names components with componentName', async () => { const Planet = z.object({ id: z.string() }).meta({ id: 'Planet' }) - const doc = await generator.generate({ - planet: oc.input(z.object({ planet: Planet })), - }, { - componentName: defName => `Api${defName}`, - }) + const doc = await generator.generate( + { + planet: oc.input(z.object({ planet: Planet })), + }, + { + componentName: (defName) => `Api${defName}`, + }, + ) - expect((doc.paths?.['/planet']?.post?.requestBody as any).content['application/json'].schema).toEqual( + expect( + (doc.paths?.['/planet']?.post?.requestBody as any).content['application/json'].schema, + ).toEqual( expect.objectContaining({ properties: { planet: { $ref: '#/components/schemas/ApiPlanet' }, @@ -247,34 +293,46 @@ describe('openAPIGenerator e2e: reusable component schemas', () => { it('references components from detailed request and response bodies', async () => { const doc = await generator.generate({ updatePlanet: oc - .meta(openapi({ path: '/planets/{id}', inputStructure: 'detailed', outputStructure: 'detailed' })) - .input(z.object({ - params: z.object({ id: z.string() }), - body: z.object({ name: z.string() }).meta({ id: 'UpdatePlanetInput' }), - })) - .output(z.object({ - body: z.object({ updated: z.boolean() }).meta({ id: 'UpdatePlanetOutput' }), - })), + .meta( + openapi({ + path: '/planets/{id}', + inputStructure: 'detailed', + outputStructure: 'detailed', + }), + ) + .input( + z.object({ + params: z.object({ id: z.string() }), + body: z.object({ name: z.string() }).meta({ id: 'UpdatePlanetInput' }), + }), + ) + .output( + z.object({ + body: z.object({ updated: z.boolean() }).meta({ id: 'UpdatePlanetOutput' }), + }), + ), }) - expect(doc.paths?.['/planets/{id}']?.post).toEqual(expect.objectContaining({ - requestBody: { - required: true, - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/UpdatePlanetInput' }, - }, - }, - }, - responses: { - 200: expect.objectContaining({ + expect(doc.paths?.['/planets/{id}']?.post).toEqual( + expect.objectContaining({ + requestBody: { + required: true, content: { 'application/json': { - schema: { $ref: '#/components/schemas/UpdatePlanetOutput' }, + schema: { $ref: '#/components/schemas/UpdatePlanetInput' }, }, }, - }), - }, - })) + }, + responses: { + 200: expect.objectContaining({ + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UpdatePlanetOutput' }, + }, + }, + }), + }, + }), + ) }) }) diff --git a/packages/openapi/tests/openapi-generator/schema-libraries.test.ts b/packages/openapi/tests/openapi-generator/schema-libraries.test.ts index 8ff3ddad88..be49ee9f7c 100644 --- a/packages/openapi/tests/openapi-generator/schema-libraries.test.ts +++ b/packages/openapi/tests/openapi-generator/schema-libraries.test.ts @@ -1,11 +1,14 @@ import { oc } from '@orpc/contract' import * as arktype from 'arktype' -import z from 'zod' -import { openapi, OpenAPIGenerator } from '../../src' -import { testSchema, testSchemaConverter, zodJsonSchemaConverter } from '../__shared__/schema' +import * as z from 'zod' + +import { openapi, OpenAPIGenerator } from '../../src/index.ts' +import { testSchema, testSchemaConverter, zodJsonSchemaConverter } from '../__shared__/schema.ts' describe('openAPIGenerator e2e: schema library agnosticism', () => { - const generator = new OpenAPIGenerator({ converters: [testSchemaConverter, zodJsonSchemaConverter] }) + const generator = new OpenAPIGenerator({ + converters: [testSchemaConverter, zodJsonSchemaConverter], + }) it('generates the same operation shape from zod, arktype, and plain JSON schemas', async () => { const doc = await generator.generate({ @@ -19,43 +22,53 @@ describe('openAPIGenerator e2e: schema library agnosticism', () => { .output(arktype.type({ id: 'string' })), withJsonSchema: oc .meta(openapi({ method: 'POST', path: '/json-schema' })) - .input(testSchema({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] })) - .output(testSchema({ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] })), + .input( + testSchema({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }), + ) + .output( + testSchema({ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }), + ), }) for (const path of ['/zod', '/arktype', '/json-schema'] as const) { - expect(doc.paths?.[path]?.post, path).toEqual(expect.objectContaining({ - requestBody: { - required: true, - content: { - 'application/json': { - schema: expect.objectContaining({ - type: 'object', - properties: expect.objectContaining({ - name: expect.objectContaining({ type: 'string' }), - }), - required: ['name'], - }), - }, - }, - }, - responses: { - 200: { - description: 'OK', + expect(doc.paths?.[path]?.post, path).toEqual( + expect.objectContaining({ + requestBody: { + required: true, content: { 'application/json': { schema: expect.objectContaining({ type: 'object', properties: expect.objectContaining({ - id: expect.objectContaining({ type: 'string' }), + name: expect.objectContaining({ type: 'string' }), }), - required: ['id'], + required: ['name'], }), }, }, }, - }, - })) + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ + id: expect.objectContaining({ type: 'string' }), + }), + required: ['id'], + }), + }, + }, + }, + }, + }), + ) } }) @@ -67,7 +80,12 @@ describe('openAPIGenerator e2e: schema library agnosticism', () => { }) expect(doc.paths?.['/planets/{id}']?.get?.parameters).toEqual([ - { name: 'id', in: 'path', required: true, schema: expect.objectContaining({ type: 'string' }) }, + { + name: 'id', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, { name: 'expand', in: 'query', @@ -80,9 +98,8 @@ describe('openAPIGenerator e2e: schema library agnosticism', () => { it('supports plain JSON schemas with $defs, hoisting them into components', async () => { const doc = await generator.generate({ - createPlanet: oc - .meta(openapi({ method: 'POST', path: '/planets' })) - .input(testSchema({ + createPlanet: oc.meta(openapi({ method: 'POST', path: '/planets' })).input( + testSchema({ type: 'object', properties: { planet: { $ref: '#/$defs/Planet' } }, required: ['planet'], @@ -93,10 +110,13 @@ describe('openAPIGenerator e2e: schema library agnosticism', () => { required: ['id'], }, }, - })), + }), + ), }) - expect((doc.paths?.['/planets']?.post?.requestBody as any).content['application/json'].schema).toEqual({ + expect( + (doc.paths?.['/planets']?.post?.requestBody as any).content['application/json'].schema, + ).toEqual({ type: 'object', properties: { planet: { $ref: '#/components/schemas/Planet' } }, required: ['planet'], diff --git a/packages/openapi/tests/openapi-generator/typed-errors.test.ts b/packages/openapi/tests/openapi-generator/typed-errors.test.ts index bc19d72de8..5c19a7c1f6 100644 --- a/packages/openapi/tests/openapi-generator/typed-errors.test.ts +++ b/packages/openapi/tests/openapi-generator/typed-errors.test.ts @@ -1,7 +1,8 @@ import { oc } from '@orpc/contract' -import z from 'zod' -import { openapi, OpenAPIGenerator } from '../../src' -import { zodJsonSchemaConverter } from '../__shared__/schema' +import * as z from 'zod' + +import { openapi, OpenAPIGenerator } from '../../src/index.ts' +import { zodJsonSchemaConverter } from '../__shared__/schema.ts' describe('openAPIGenerator e2e: typed errors', () => { const generator = new OpenAPIGenerator({ converters: [zodJsonSchemaConverter] }) @@ -59,18 +60,20 @@ describe('openAPIGenerator e2e: typed errors', () => { }, }) - expect(doc.paths?.['/planets/{id}']?.get?.responses?.['404']).toEqual(expect.objectContaining({ - content: { - 'application/json': { - schema: { - oneOf: [ - { $ref: '#/components/schemas/NotFound' }, - { $ref: '#/components/schemas/UndefinedError' }, - ], + expect(doc.paths?.['/planets/{id}']?.get?.responses?.['404']).toEqual( + expect.objectContaining({ + content: { + 'application/json': { + schema: { + oneOf: [ + { $ref: '#/components/schemas/NotFound' }, + { $ref: '#/components/schemas/UndefinedError' }, + ], + }, }, }, - }, - })) + }), + ) }) it('builds error components with defined, code, message, and data', async () => { @@ -107,40 +110,48 @@ describe('openAPIGenerator e2e: typed errors', () => { }) }) - it.each(['3.2.0', '3.1.0', '3.0.3'] as const)('titles error components after their codes so oneOf branches sharing a status stay distinguishable in OpenAPI %s', async (version) => { - const doc = await generator.generate({ - createPlanet: oc - .meta(openapi({ method: 'POST', path: '/planets' })) - .errors({ - PLANET_NAME_TAKEN: { data: z.object({ existingId: z.string() }) }, - PLANET_QUOTA_EXCEEDED: {}, - }) - .input(z.object({ name: z.string() })), - }, { - version, - errorStatusMap: { PLANET_NAME_TAKEN: 409, PLANET_QUOTA_EXCEEDED: 409 }, - }) - - expect(doc.paths?.['/planets']?.post?.responses?.['409']).toEqual(expect.objectContaining({ - content: { - 'application/json': { - schema: { - oneOf: [ - { $ref: '#/components/schemas/PlanetNameTaken' }, - { $ref: '#/components/schemas/PlanetQuotaExceeded' }, - { $ref: '#/components/schemas/UndefinedError' }, - ], - }, + it.each(['3.2.0', '3.1.0', '3.0.3'] as const)( + 'titles error components after their codes so oneOf branches sharing a status stay distinguishable in OpenAPI %s', + async (version) => { + const doc = await generator.generate( + { + createPlanet: oc + .meta(openapi({ method: 'POST', path: '/planets' })) + .errors({ + PLANET_NAME_TAKEN: { data: z.object({ existingId: z.string() }) }, + PLANET_QUOTA_EXCEEDED: {}, + }) + .input(z.object({ name: z.string() })), }, - }, - })) + { + version, + errorStatusMap: { PLANET_NAME_TAKEN: 409, PLANET_QUOTA_EXCEEDED: 409 }, + }, + ) + + expect(doc.paths?.['/planets']?.post?.responses?.['409']).toEqual( + expect.objectContaining({ + content: { + 'application/json': { + schema: { + oneOf: [ + { $ref: '#/components/schemas/PlanetNameTaken' }, + { $ref: '#/components/schemas/PlanetQuotaExceeded' }, + { $ref: '#/components/schemas/UndefinedError' }, + ], + }, + }, + }, + }), + ) - expect(doc.components?.schemas).toEqual({ - PlanetNameTaken: expect.objectContaining({ title: 'PLANET_NAME_TAKEN' }), - PlanetQuotaExceeded: expect.objectContaining({ title: 'PLANET_QUOTA_EXCEEDED' }), - UndefinedError: expect.objectContaining({ title: 'UndefinedError' }), - }) - }) + expect(doc.components?.schemas).toEqual({ + PlanetNameTaken: expect.objectContaining({ title: 'PLANET_NAME_TAKEN' }), + PlanetQuotaExceeded: expect.objectContaining({ title: 'PLANET_QUOTA_EXCEEDED' }), + UndefinedError: expect.objectContaining({ title: 'UndefinedError' }), + }) + }, + ) it('shares error components between procedures and across regenerations from a base document', async () => { const sharedRouter = { @@ -149,38 +160,47 @@ describe('openAPIGenerator e2e: typed errors', () => { } const first = await generator.generate(sharedRouter) - expect(Object.keys(first.components?.schemas ?? {}).sort()).toEqual(['NotFound', 'UndefinedError']) + expect(Object.keys(first.components?.schemas ?? {}).toSorted()).toEqual([ + 'NotFound', + 'UndefinedError', + ]) const second = await generator.generate(sharedRouter, { base: { components: first.components }, }) - expect(Object.keys(second.components?.schemas ?? {}).sort()).toEqual(['NotFound', 'UndefinedError']) + expect(Object.keys(second.components?.schemas ?? {}).toSorted()).toEqual([ + 'NotFound', + 'UndefinedError', + ]) }) it('supports a custom error status map and a custom error body schema', async () => { - const doc = await generator.generate({ - getPlanet: oc - .meta(openapi({ method: 'GET', path: '/planets/{id}' })) - .errors({ PLANET_GONE: { message: 'Planet is gone' } }) - .input(z.object({ id: z.string() })), - }, { - errorStatusMap: { PLANET_GONE: 410 }, - errorResponseBodySchema: (definedErrors, status) => { - expect(definedErrors).toEqual([ - expect.objectContaining({ code: 'PLANET_GONE', defaultMessage: 'Planet is gone' }), - ]) - - return { - type: 'object', - properties: { - code: { type: 'string' }, - message: { type: 'string' }, - status: { const: status }, - }, - required: ['code', 'message'], - } + const doc = await generator.generate( + { + getPlanet: oc + .meta(openapi({ method: 'GET', path: '/planets/{id}' })) + .errors({ PLANET_GONE: { message: 'Planet is gone' } }) + .input(z.object({ id: z.string() })), }, - }) + { + errorStatusMap: { PLANET_GONE: 410 }, + errorResponseBodySchema: (definedErrors, status) => { + expect(definedErrors).toEqual([ + expect.objectContaining({ code: 'PLANET_GONE', defaultMessage: 'Planet is gone' }), + ]) + + return { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + status: { const: status }, + }, + required: ['code', 'message'], + } + }, + }, + ) expect(doc.paths?.['/planets/{id}']?.get?.responses?.['410']).toEqual({ description: 'Planet is gone', @@ -203,15 +223,18 @@ describe('openAPIGenerator e2e: typed errors', () => { }) it('supports a custom error response content type', async () => { - const doc = await generator.generate({ - getPlanet: oc - .meta(openapi({ method: 'GET', path: '/planets/{id}' })) - .errors({ NOT_FOUND: {} }) - .input(z.object({ id: z.string() })) - .output(z.object({ name: z.string() })), - }, { - errorResponseContentType: 'application/problem+json', - }) + const doc = await generator.generate( + { + getPlanet: oc + .meta(openapi({ method: 'GET', path: '/planets/{id}' })) + .errors({ NOT_FOUND: {} }) + .input(z.object({ id: z.string() })) + .output(z.object({ name: z.string() })), + }, + { + errorResponseContentType: 'application/problem+json', + }, + ) const responses = doc.paths?.['/planets/{id}']?.get?.responses as any expect(Object.keys(responses['200'].content)).toEqual(['application/json']) diff --git a/packages/openapi/tsconfig.json b/packages/openapi/tsconfig.json index 4cf9ae9db4..9979a0715c 100644 --- a/packages/openapi/tsconfig.json +++ b/packages/openapi/tsconfig.json @@ -1,19 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../contract" }, - { "path": "../json-schema" }, - { "path": "../server" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.bench.*", - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 35ee468fd6..3d53b9f08c 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/opentelemetry/build.config.ts b/packages/opentelemetry/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/opentelemetry/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index ca1104fbe1..28382b9f5f 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -1,52 +1,47 @@ { "name": "@orpc/opentelemetry", - "type": "module", "version": "2.0.0-beta.48", "description": "OpenTelemetry integration for oRPC: distributed tracing for typesafe APIs", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "observability", + "opentelemetry", + "orpc", + "otel", + "tracing", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/opentelemetry" }, - "keywords": [ - "orpc", - "opentelemetry", - "otel", - "tracing", - "observability", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0", - "@opentelemetry/instrumentation": ">=0.203.0" + "prepack": "tsc" }, "dependencies": { "@orpc/shared": "workspace:*" @@ -55,5 +50,9 @@ "@opentelemetry/api": "^1.10.0", "@opentelemetry/instrumentation": "^0.300.0", "@opentelemetry/sdk-trace-node": "^2.12.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0", + "@opentelemetry/instrumentation": ">=0.203.0" } } diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 8cf1e4c454..153914a953 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -1 +1 @@ -export * from './instrumentation' +export * from './instrumentation.ts' diff --git a/packages/opentelemetry/src/instrumentation.test.ts b/packages/opentelemetry/src/instrumentation.test.ts index bceee9d92b..08695f416c 100644 --- a/packages/opentelemetry/src/instrumentation.test.ts +++ b/packages/opentelemetry/src/instrumentation.test.ts @@ -1,10 +1,15 @@ import { trace } from '@opentelemetry/api' import { registerInstrumentations } from '@opentelemetry/instrumentation' -import { InMemorySpanExporter, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node' +import { + InMemorySpanExporter, + NodeTracerProvider, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-node' import { getTracer, setTracer } from '@orpc/shared' -import pkg from '../package.json' -import { ORPCInstrumentation } from './instrumentation' -import { OpenTelemetryTracer } from './tracer' + +import pkg from '../package.json' with { type: 'json' } +import { ORPCInstrumentation } from './instrumentation.ts' +import { OpenTelemetryTracer } from './tracer.ts' const getTracerSpy = vi.spyOn(trace, 'getTracer') @@ -65,12 +70,18 @@ describe('oRPCInstrumentation', () => { it.each([ ['an enabled', {}], ['a disabled', { enabled: false }], - ])('receives the spans of %s instrumentation without being registered globally', (_, config) => { - registerInstrumentations({ instrumentations: [new ORPCInstrumentation(config)], tracerProvider: provider }) - - getTracer()!.startSpan('span').end() - - expect(exporter.getFinishedSpans()).toHaveLength(1) - }) + ])( + 'receives the spans of %s instrumentation without being registered globally', + (_, config) => { + registerInstrumentations({ + instrumentations: [new ORPCInstrumentation(config)], + tracerProvider: provider, + }) + + getTracer()!.startSpan('span').end() + + expect(exporter.getFinishedSpans()).toHaveLength(1) + }, + ) }) }) diff --git a/packages/opentelemetry/src/instrumentation.ts b/packages/opentelemetry/src/instrumentation.ts index 33c15cf21c..0d9b2d2b4d 100644 --- a/packages/opentelemetry/src/instrumentation.ts +++ b/packages/opentelemetry/src/instrumentation.ts @@ -1,8 +1,9 @@ import type { InstrumentationConfig } from '@opentelemetry/instrumentation' import { InstrumentationBase } from '@opentelemetry/instrumentation' import { getTracer, setTracer } from '@orpc/shared' + import pkg from '../package.json' with { type: 'json' } -import { OpenTelemetryTracer } from './tracer' +import { OpenTelemetryTracer } from './tracer.ts' export interface ORPCInstrumentationConfig extends InstrumentationConfig { /** @@ -29,10 +30,12 @@ export class ORPCInstrumentation extends InstrumentationBase this.tracer, - propagationEnabled: this.getConfig().propagationEnabled, - })) + setTracer( + new OpenTelemetryTracer({ + tracer: () => this.tracer, + propagationEnabled: this.getConfig().propagationEnabled, + }), + ) } override disable(): void { diff --git a/packages/opentelemetry/src/tracer.no-context-manager.test.ts b/packages/opentelemetry/src/tracer.no-context-manager.test.ts index a38769909a..a85c42acc0 100644 --- a/packages/opentelemetry/src/tracer.no-context-manager.test.ts +++ b/packages/opentelemetry/src/tracer.no-context-manager.test.ts @@ -1,7 +1,12 @@ -import type { TracingHeaders } from '@orpc/shared' import { context, propagation, trace } from '@opentelemetry/api' -import { InMemorySpanExporter, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node' -import { OpenTelemetryTracer } from './tracer' +import { + InMemorySpanExporter, + NodeTracerProvider, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-node' +import type { TracingHeaders } from '@orpc/shared' + +import { OpenTelemetryTracer } from './tracer.ts' /** * Some setups (e.g. browsers or React Native without a context manager) cannot activate spans, @@ -35,9 +40,11 @@ it('keeps explicit parents and injects trace context without a context manager', parent.end() }) - const parent = exporter.getFinishedSpans().find(span => span.name === 'parent')! - const child = exporter.getFinishedSpans().find(span => span.name === 'child')! + const parent = exporter.getFinishedSpans().find((span) => span.name === 'parent')! + const child = exporter.getFinishedSpans().find((span) => span.name === 'child')! expect(child.parentSpanContext?.spanId).toBe(parent.spanContext().spanId) - expect(headers.traceparent).toBe(`00-${parent.spanContext().traceId}-${parent.spanContext().spanId}-01`) + expect(headers.traceparent).toBe( + `00-${parent.spanContext().traceId}-${parent.spanContext().spanId}-01`, + ) }) diff --git a/packages/opentelemetry/src/tracer.test.ts b/packages/opentelemetry/src/tracer.test.ts index de160368b8..f8ea73ed5e 100644 --- a/packages/opentelemetry/src/tracer.test.ts +++ b/packages/opentelemetry/src/tracer.test.ts @@ -1,7 +1,12 @@ -import type { TracingHeaders } from '@orpc/shared' import { context, propagation, SpanStatusCode, trace } from '@opentelemetry/api' -import { InMemorySpanExporter, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node' -import { OpenTelemetrySpan, OpenTelemetryTracer } from './tracer' +import { + InMemorySpanExporter, + NodeTracerProvider, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-node' +import type { TracingHeaders } from '@orpc/shared' + +import { OpenTelemetrySpan, OpenTelemetryTracer } from './tracer.ts' const exporter = new InMemorySpanExporter() const provider = new NodeTracerProvider({ @@ -34,7 +39,7 @@ function activeBaggageUser() { } function finishedSpan(name: string) { - const span = exporter.getFinishedSpans().find(span => span.name === name) + const span = exporter.getFinishedSpans().find((span) => span.name === name) expect(span).toBeDefined() return span! } @@ -61,7 +66,7 @@ describe('openTelemetryTracer', () => { const span = finishedSpan('renamed') expect(span.attributes).toEqual({ key: 'value', list: ['a', 'b'] }) - expect(span.events.map(event => event.name)).toEqual(['event']) + expect(span.events.map((event) => event.name)).toEqual(['event']) }) it('parents active spans on the parent option', async () => { @@ -129,7 +134,12 @@ describe('openTelemetryTracer', () => { const tracer = createTracer() const failed = tracer.startSpan('failed') - failed.recordException('error', { name: 'Error', message: 'boom', stack: 'stack', code: 'CODE' }) + failed.recordException('error', { + name: 'Error', + message: 'boom', + stack: 'stack', + code: 'CODE', + }) failed.end() const aborted = tracer.startSpan('aborted') @@ -234,7 +244,7 @@ describe('openTelemetryTracer', () => { const parent = tracer.extract!({ baggage: 'user=alice' }) // Like the server handler: the span outlives `startActiveSpan` and is re-activated per stream pull - const handler = await tracer.startActiveSpan('handler', parent, async span => span) + const handler = await tracer.startActiveSpan('handler', parent, async (span) => span) expect(propagation.getActiveBaggage()).toBeUndefined() @@ -256,7 +266,9 @@ describe('openTelemetryTracer', () => { handler.end() - expect(finishedSpan('child').parentSpanContext?.spanId).toBe(finishedSpan('handler').spanContext().spanId) + expect(finishedSpan('child').parentSpanContext?.spanId).toBe( + finishedSpan('handler').spanContext().spanId, + ) expect(headers.baggage).toBe('user=alice') }) @@ -289,7 +301,9 @@ describe('openTelemetryTracer', () => { http.end() }) - expect(finishedSpan('handler').parentSpanContext?.spanId).toBe(finishedSpan('http').spanContext().spanId) + expect(finishedSpan('handler').parentSpanContext?.spanId).toBe( + finishedSpan('http').spanContext().spanId, + ) }) it('injects the baggage of the context the active span was read in', async () => { @@ -309,20 +323,35 @@ describe('openTelemetryTracer', () => { it('keeps baggage already in the active context when extracting, and lets it win on conflict', async () => { const tracer = createTracer() - const baggage = propagation.createBaggage({ tenant: { value: 't1' }, user: { value: 'server' } }) + const baggage = propagation.createBaggage({ + tenant: { value: 't1' }, + user: { value: 'server' }, + }) const parent = context.with(propagation.setBaggage(context.active(), baggage), () => - tracer.extract!({ baggage: 'user=alice,region=eu' })) + tracer.extract!({ baggage: 'user=alice,region=eu' }), + ) await tracer.withActiveSpan(parent!, () => { - const entries = Object.fromEntries(propagation.getActiveBaggage()!.getAllEntries().map(([key, entry]) => [key, entry.value])) + const entries = Object.fromEntries( + propagation + .getActiveBaggage()! + .getAllEntries() + .map(([key, entry]) => [key, entry.value]), + ) expect(entries).toEqual({ tenant: 't1', user: 'server', region: 'eu' }) }) }) it('falls back to the active context for spans of another tracer', async () => { const tracer = createTracer() - const foreign = { setAttribute() {}, updateName() {}, addEvent() {}, recordException() {}, end() {} } + const foreign = { + setAttribute() {}, + updateName() {}, + addEvent() {}, + recordException() {}, + end() {}, + } const headers: TracingHeaders = {} await tracer.startActiveSpan('outer', undefined, async (outer) => { @@ -334,7 +363,9 @@ describe('openTelemetryTracer', () => { const outer = finishedSpan('outer') expect(finishedSpan('child').parentSpanContext?.spanId).toBe(outer.spanContext().spanId) - expect(headers.traceparent).toBe(`00-${outer.spanContext().traceId}-${outer.spanContext().spanId}-01`) + expect(headers.traceparent).toBe( + `00-${outer.spanContext().traceId}-${outer.spanContext().spanId}-01`, + ) }) it('falls back to the active span when headers carry no trace context', async () => { @@ -343,7 +374,9 @@ describe('openTelemetryTracer', () => { expect(tracer.extract!({})).toBeUndefined() await tracer.startActiveSpan('outer', undefined, async (outer) => { - expect((tracer.extract!({}) as OpenTelemetrySpan).span).toBe((outer as OpenTelemetrySpan).span) + expect((tracer.extract!({}) as OpenTelemetrySpan).span).toBe( + (outer as OpenTelemetrySpan).span, + ) outer.end() }) }) diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts index 978452b7a3..031bfe1972 100644 --- a/packages/opentelemetry/src/tracer.ts +++ b/packages/opentelemetry/src/tracer.ts @@ -1,6 +1,19 @@ import type { Context, Tracer as OpenTelemetryApiTracer, Span } from '@opentelemetry/api' -import type { Promisable, Tracer, TracingAttributeValue, TracingException, TracingExceptionLevel, TracingSpan } from '@orpc/shared' -import { context, INVALID_SPAN_CONTEXT, propagation, SpanStatusCode, trace } from '@opentelemetry/api' +import { + context, + INVALID_SPAN_CONTEXT, + propagation, + SpanStatusCode, + trace, +} from '@opentelemetry/api' +import type { + Promisable, + Tracer, + TracingAttributeValue, + TracingException, + TracingExceptionLevel, + TracingSpan, +} from '@orpc/shared' export class OpenTelemetrySpan implements TracingSpan { readonly span: Span @@ -87,7 +100,11 @@ export class OpenTelemetryTracer implements Tracer { return new OpenTelemetrySpan(trace.setSpan(parentContext, span)) } - startActiveSpan(name: string, parent: TracingSpan | undefined, fn: (span: TracingSpan) => Promise): Promise { + startActiveSpan( + name: string, + parent: TracingSpan | undefined, + fn: (span: TracingSpan) => Promise, + ): Promise { // Same as the OpenTelemetry SDK's startActiveSpan, but the stored context does not depend on a context manager const span = this.startSpan(name, parent) as OpenTelemetrySpan return context.with(span.context, fn, undefined, span) @@ -122,7 +139,10 @@ function withActiveBaggage(extracted: Context, active: Context): Context { return extracted } - return propagation.setBaggage(extracted, propagation.createBaggage( - Object.fromEntries([...fromHeaders.getAllEntries(), ...current.getAllEntries()]), - )) + return propagation.setBaggage( + extracted, + propagation.createBaggage( + Object.fromEntries([...fromHeaders.getAllEntries(), ...current.getAllEntries()]), + ), + ) } diff --git a/packages/opentelemetry/tsconfig.json b/packages/opentelemetry/tsconfig.json index 5e8e00aae1..9979a0715c 100644 --- a/packages/opentelemetry/tsconfig.json +++ b/packages/opentelemetry/tsconfig.json @@ -1,14 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../shared" } - ], - "include": ["./package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index b8c5bcf177..4528dbfb09 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/pinia-colada/build.config.ts b/packages/pinia-colada/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/pinia-colada/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/pinia-colada/package.json b/packages/pinia-colada/package.json index b21225f5a8..cc244710fe 100644 --- a/packages/pinia-colada/package.json +++ b/packages/pinia-colada/package.json @@ -1,54 +1,49 @@ { "name": "@orpc/pinia-colada", - "type": "module", "version": "2.0.0-beta.48", "description": "Pinia Colada integration for oRPC: typesafe queries and mutations for Vue", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "mutation", + "orpc", + "pinia", + "pinia-colada", + "query", + "typesafe", + "typescript", + "vue" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/pinia-colada" }, - "keywords": [ - "orpc", - "vue", - "pinia", - "pinia-colada", - "query", - "mutation", - "typescript", - "typesafe" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "@pinia/colada": ">=1.2.0", - "vue": ">=3.5.17" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", @@ -62,5 +57,9 @@ "pinia": "^4.1.0", "vue": "^3.5.44", "zod": "^4.6.7" + }, + "peerDependencies": { + "@pinia/colada": ">=1.2.0", + "vue": ">=3.5.17" } } diff --git a/packages/pinia-colada/src/contract-utils.test-d.ts b/packages/pinia-colada/src/contract-utils.test-d.ts index ceeb4e8e69..3fef095f73 100644 --- a/packages/pinia-colada/src/contract-utils.test-d.ts +++ b/packages/pinia-colada/src/contract-utils.test-d.ts @@ -1,12 +1,16 @@ import type { ORPCError } from '@orpc/client' import type { ContractClientFactory } from '@orpc/contract' +import { meta, oc, type } from '@orpc/contract' import type { ContractJsonifiedClientFactory } from '@orpc/openapi' import type { PromiseWithError } from '@orpc/shared' -import type { ProcedureUtils } from './procedure-utils' -import type { SharedUtils } from './shared-utils' -import type { OperationContext } from './types' -import { meta, oc, type } from '@orpc/contract' -import { createContractJsonifiedUtilsFactory, createContractUtilsFactory } from './contract-utils' + +import { + createContractJsonifiedUtilsFactory, + createContractUtilsFactory, +} from './contract-utils.ts' +import type { ProcedureUtils } from './procedure-utils.ts' +import type { SharedUtils } from './shared-utils.ts' +import type { OperationContext } from './types.ts' const contract = { ping: oc.meta(meta.path(['ping'])), @@ -32,9 +36,7 @@ describe('createContractUtilsFactory', () => { const result = next() - expectTypeOf(result).toEqualTypeOf< - PromiseWithError - >() + expectTypeOf(result).toEqualTypeOf>() return result }, @@ -83,7 +85,10 @@ describe('createContractUtilsFactory', () => { const utils = createUtils(contract.nested.pong) expectTypeOf(utils).toEqualTypeOf< - & Omit>, 'path' | 'options'> + Omit< + ProcedureUtils<{ cache?: boolean }, string, Date, Error | ORPCError<'BAD_GATEWAY', URL>>, + 'path' | 'options' + > >() }) @@ -92,7 +97,10 @@ describe('createContractUtilsFactory', () => { const utils = createUtils(contract) expectTypeOf(utils.nested.pong).toEqualTypeOf< - & Omit>, 'path' | 'options'> + Omit< + ProcedureUtils<{ cache?: boolean }, string, Date, Error | ORPCError<'BAD_GATEWAY', URL>>, + 'path' | 'options' + > >() expectTypeOf(utils.key).toEqualTypeOf['key']>() @@ -109,9 +117,7 @@ describe('createContractJsonifiedUtilsFactory', () => { const result = next() - expectTypeOf(result).toEqualTypeOf< - PromiseWithError - >() + expectTypeOf(result).toEqualTypeOf>() return result }, @@ -160,7 +166,15 @@ describe('createContractJsonifiedUtilsFactory', () => { const utils = createUtils(contract.nested.pong) expectTypeOf(utils).toEqualTypeOf< - & Omit>, 'path' | 'options'> + Omit< + ProcedureUtils< + { cache?: boolean }, + string, + string, + Error | ORPCError<'BAD_GATEWAY', string> + >, + 'path' | 'options' + > >() }) @@ -169,7 +183,15 @@ describe('createContractJsonifiedUtilsFactory', () => { const utils = createUtils(contract) expectTypeOf(utils.nested.pong).toEqualTypeOf< - & Omit>, 'path' | 'options'> + Omit< + ProcedureUtils< + { cache?: boolean }, + string, + string, + Error | ORPCError<'BAD_GATEWAY', string> + >, + 'path' | 'options' + > >() }) }) diff --git a/packages/pinia-colada/src/contract-utils.test.ts b/packages/pinia-colada/src/contract-utils.test.ts index 61ac58cb37..155e7de371 100644 --- a/packages/pinia-colada/src/contract-utils.test.ts +++ b/packages/pinia-colada/src/contract-utils.test.ts @@ -1,6 +1,10 @@ import { ProcedureContract } from '@orpc/contract' -import { createContractJsonifiedUtilsFactory, createContractUtilsFactory } from './contract-utils' -import * as routerUtilsModule from './router-utils' + +import { + createContractJsonifiedUtilsFactory, + createContractUtilsFactory, +} from './contract-utils.ts' +import * as routerUtilsModule from './router-utils.ts' const createRouterUtilsSpy = vi.spyOn(routerUtilsModule, 'createRouterUtils') diff --git a/packages/pinia-colada/src/contract-utils.ts b/packages/pinia-colada/src/contract-utils.ts index 489461a094..edfca23fa2 100644 --- a/packages/pinia-colada/src/contract-utils.ts +++ b/packages/pinia-colada/src/contract-utils.ts @@ -1,19 +1,20 @@ import type { ClientContext } from '@orpc/client' import type { ContractClientFactory, RouterContract, RouterContractClient } from '@orpc/contract' -import type { ContractJsonifiedClientFactory, JsonifiedClient } from '@orpc/openapi' -import type { RouterUtils, RouterUtilsOptions } from './router-utils' import { resolveBasePathMeta } from '@orpc/contract' +import type { ContractJsonifiedClientFactory, JsonifiedClient } from '@orpc/openapi' import { get } from '@orpc/shared' -import { createRouterUtils } from './router-utils' + +import type { RouterUtils, RouterUtilsOptions } from './router-utils.ts' +import { createRouterUtils } from './router-utils.ts' export interface ContractUtilsFactory { (contract: T): RouterUtils> } -export interface ContractUtilsFactoryOptions< - TClientContext extends ClientContext, -> extends Omit>, 'path'> { -} +export interface ContractUtilsFactoryOptions extends Omit< + RouterUtilsOptions>, + 'path' +> {} /** * Creates a factory that builds Pinia Colada utils directly from a contract, @@ -25,9 +26,7 @@ export interface ContractUtilsFactoryOptions< * * @see {@link https://orpc.dev/docs/contract/client-factory#pinia-colada-integration | Contract Client Factory - Pinia Colada Integration} */ -export function createContractUtilsFactory< - TClientContext extends ClientContext, ->( +export function createContractUtilsFactory( clientFactory: ContractClientFactory, options: ContractUtilsFactoryOptions, ): ContractUtilsFactory { @@ -41,20 +40,28 @@ export function createContractUtilsFactory< ) } - return createRouterUtils(client, { ...options as any, path, scoped: get(options.scoped, path) }) + return createRouterUtils(client, { + ...(options as any), + path, + scoped: get(options.scoped, path), + }) } return factory } export interface ContractJsonifiedUtilsFactory { - (contract: T): RouterUtils>> + ( + contract: T, + ): RouterUtils>> } export interface ContractJsonifiedUtilsFactoryOptions< TClientContext extends ClientContext, -> extends Omit>>, 'path'> { -} +> extends Omit< + RouterUtilsOptions>>, + 'path' +> {} /** * Like `createContractUtilsFactory`, but for clients whose types are wrapped in `JsonifiedClient`, @@ -67,11 +74,12 @@ export interface ContractJsonifiedUtilsFactoryOptions< * * @see {@link https://orpc.dev/docs/contract/client-factory#pinia-colada-integration | Contract Client Factory - Pinia Colada Integration} */ -export function createContractJsonifiedUtilsFactory< - TClientContext extends ClientContext, ->( +export function createContractJsonifiedUtilsFactory( clientFactory: ContractJsonifiedClientFactory, options: ContractJsonifiedUtilsFactoryOptions, ): ContractJsonifiedUtilsFactory { - return createContractUtilsFactory(clientFactory as ContractClientFactory, options) as ContractJsonifiedUtilsFactory + return createContractUtilsFactory( + clientFactory as ContractClientFactory, + options, + ) as ContractJsonifiedUtilsFactory } diff --git a/packages/pinia-colada/src/index.test.ts b/packages/pinia-colada/src/index.test.ts index 6509b3e8d8..19c9414af7 100644 --- a/packages/pinia-colada/src/index.test.ts +++ b/packages/pinia-colada/src/index.test.ts @@ -1,5 +1,5 @@ it('exports createPiniaColadaUtils, PINIA_COLADA_OPERATION_CONTEXT_SYMBOL', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ createPiniaColadaUtils: expect.any(Function), createRouterUtils: expect.any(Function), PINIA_COLADA_OPERATION_CONTEXT_SYMBOL: expect.any(Symbol), diff --git a/packages/pinia-colada/src/index.ts b/packages/pinia-colada/src/index.ts index fb59f1fa63..a9b69a738e 100644 --- a/packages/pinia-colada/src/index.ts +++ b/packages/pinia-colada/src/index.ts @@ -1,13 +1,13 @@ -export * from './contract-utils' +export * from './contract-utils.ts' export type { OperationKey, OperationKeyBaseOptions, OperationKeyOptions, OperationKeyPrefixOptions, -} from './key' -export * from './meta' -export type { RouterUtilsPlugin } from './plugin' -export { mergeProcedureUtilsOptions, ProcedureUtils } from './procedure-utils' +} from './key.ts' +export * from './meta.ts' +export type { RouterUtilsPlugin } from './plugin.ts' +export { mergeProcedureUtilsOptions, ProcedureUtils } from './procedure-utils.ts' export type { ProcedureUtilsInfiniteInterceptor, ProcedureUtilsInfiniteInterceptorOptions, @@ -21,29 +21,23 @@ export type { ProcedureUtilsQueryInterceptorOptions, ProcedureUtilsStreamedInterceptor, ProcedureUtilsStreamedInterceptorOptions, -} from './procedure-utils' -export * from './router-utils' +} from './procedure-utils.ts' +export * from './router-utils.ts' export { /** * @deprecated Use `createRouterUtils` instead. */ createRouterUtils as createORPCVueColadaUtils, -} from './router-utils' -export { - createRouterUtils as createPiniaColadaUtils, -} from './router-utils' -export { SharedUtils } from './shared-utils' -export type { SerializableStreamedQueryOptions } from './stream-query' -export * from './types' +} from './router-utils.ts' +export { createRouterUtils as createPiniaColadaUtils } from './router-utils.ts' +export { SharedUtils } from './shared-utils.ts' +export type { SerializableStreamedQueryOptions } from './stream-query.ts' +export * from './types.ts' export { OPERATION_CONTEXT_SYMBOL as PINIA_COLADA_OPERATION_CONTEXT_SYMBOL, type OperationContext as PiniaColadaOperationContext, -} from './types' +} from './types.ts' -export type { - AnyNestedClient, -} from '@orpc/client' +export type { AnyNestedClient } from '@orpc/client' -export type { - Public, -} from '@orpc/shared' +export type { Public } from '@orpc/shared' diff --git a/packages/pinia-colada/src/key.test.ts b/packages/pinia-colada/src/key.test.ts index 82e5205783..e6173a5de1 100644 --- a/packages/pinia-colada/src/key.test.ts +++ b/packages/pinia-colada/src/key.test.ts @@ -2,65 +2,100 @@ import { RPCJsonSerializer } from '@orpc/client' import { OpenAPIJsonSerializer } from '@orpc/openapi' import { useQueryCache } from '@pinia/colada' import { createPinia } from 'pinia' -import { generateOperationKey } from './key' + +import { generateOperationKey } from './key.ts' it('generateOperationKey', () => { expect(generateOperationKey(['path'])).toEqual([['path'], {}]) - expect(generateOperationKey(['path', 'path2'], { input: { a: 1 } })).toEqual([['path', 'path2'], { input: { a: 1 } }]) + expect(generateOperationKey(['path', 'path2'], { input: { a: 1 } })).toEqual([ + ['path', 'path2'], + { input: { a: 1 } }, + ]) expect(generateOperationKey(['path'], { input: undefined })).toEqual([['path'], {}]) - expect(generateOperationKey(['path', 'path2'], { type: 'query' })).toEqual([['path', 'path2'], { type: 'query' }]) + expect(generateOperationKey(['path', 'path2'], { type: 'query' })).toEqual([ + ['path', 'path2'], + { type: 'query' }, + ]) expect(generateOperationKey(['path'], { type: undefined })).toEqual([['path'], {}]) - expect(generateOperationKey(['path', 'path2'], { type: 'query', input: { a: 1 } })).toEqual([['path', 'path2'], { type: 'query', input: { a: 1 } }]) - expect(generateOperationKey(['path'], { type: 'mutation' })).toEqual([['path'], { type: 'mutation' }]) + expect(generateOperationKey(['path', 'path2'], { type: 'query', input: { a: 1 } })).toEqual([ + ['path', 'path2'], + { type: 'query', input: { a: 1 } }, + ]) + expect(generateOperationKey(['path'], { type: 'mutation' })).toEqual([ + ['path'], + { type: 'mutation' }, + ]) const date = new Date() - expect(generateOperationKey(['path', 'path2'], { input: { a: date } })).toEqual([['path', 'path2'], { input: { a: date.toISOString() }, inputMeta: { '["a"]': ['date'] } }]) - - expect(generateOperationKey(['path'], { prefix: '__prefix__' })).toEqual(['__prefix__', ['path'], {}]) + expect(generateOperationKey(['path', 'path2'], { input: { a: date } })).toEqual([ + ['path', 'path2'], + { input: { a: date.toISOString() }, inputMeta: { '["a"]': ['date'] } }, + ]) + + expect(generateOperationKey(['path'], { prefix: '__prefix__' })).toEqual([ + '__prefix__', + ['path'], + {}, + ]) expect(generateOperationKey(['path'], { prefix: undefined })).toEqual([['path'], {}]) - expect(generateOperationKey(['path'], { prefix: '__prefix__', type: 'query', input: { a: 1 } })).toEqual(['__prefix__', ['path'], { type: 'query', input: { a: 1 } }]) + expect( + generateOperationKey(['path'], { prefix: '__prefix__', type: 'query', input: { a: 1 } }), + ).toEqual(['__prefix__', ['path'], { type: 'query', input: { a: 1 } }]) }) describe('generateOperationKey input meta', () => { it('keys meta by JSON-encoded path', () => { - expect(generateOperationKey(['path'], { input: { a: { 'b.c': [1n, new Set([new URL('https://orpc.dev')])] } } })).toEqual([['path'], { - input: { a: { 'b.c': ['1', ['https://orpc.dev/']] } }, - inputMeta: { - '["a","b.c",0]': ['bigint'], - '["a","b.c",1,0]': ['url'], - '["a","b.c",1]': ['set'], + expect( + generateOperationKey(['path'], { + input: { a: { 'b.c': [1n, new Set([new URL('https://orpc.dev')])] } }, + }), + ).toEqual([ + ['path'], + { + input: { a: { 'b.c': ['1', ['https://orpc.dev/']] } }, + inputMeta: { + '["a","b.c",0]': ['bigint'], + '["a","b.c",1,0]': ['url'], + '["a","b.c",1]': ['set'], + }, }, - }]) + ]) - expect(generateOperationKey(['path'], { input: new Date(0) })).toEqual([['path'], { - input: '1970-01-01T00:00:00.000Z', - inputMeta: { '[]': ['date'] }, - }]) + expect(generateOperationKey(['path'], { input: new Date(0) })).toEqual([ + ['path'], + { + input: '1970-01-01T00:00:00.000Z', + inputMeta: { '[]': ['date'] }, + }, + ]) }) it('distinguishes inputs with the same JSON form', () => { const date = new Date(0) - expect(generateOperationKey(['path'], { input: { a: date } })) - .not - .toEqual(generateOperationKey(['path'], { input: { a: date.toISOString() } })) + expect(generateOperationKey(['path'], { input: { a: date } })).not.toEqual( + generateOperationKey(['path'], { input: { a: date.toISOString() } }), + ) }) it('keeps every type of a value serialized by chained handlers', () => { const serializer = new RPCJsonSerializer({ handlers: { timestamp: { - condition: value => typeof value === 'object' && value !== null && 'timestamp' in value, + condition: (value) => typeof value === 'object' && value !== null && 'timestamp' in value, serialize: (value: { timestamp: number }) => new Date(value.timestamp), deserialize: (value: Date) => ({ timestamp: value.getTime() }), }, }, }) - expect(generateOperationKey(['path'], { serializer, input: { a: { timestamp: 0 } } })).toEqual([['path'], { - input: { a: '1970-01-01T00:00:00.000Z' }, - inputMeta: { '["a"]': ['date', 'timestamp'] }, - }]) + expect(generateOperationKey(['path'], { serializer, input: { a: { timestamp: 0 } } })).toEqual([ + ['path'], + { + input: { a: '1970-01-01T00:00:00.000Z' }, + inputMeta: { '["a"]': ['date', 'timestamp'] }, + }, + ]) }) it('still partially matches entries in the query cache', () => { @@ -68,24 +103,41 @@ describe('generateOperationKey input meta', () => { const from = new Date(0) const to = new Date(1) - queryCache.setQueryData(generateOperationKey(['list'], { type: 'query', input: { from, to } }), 'date') - queryCache.setQueryData(generateOperationKey(['list'], { type: 'query', input: { from: from.toISOString(), to } }), 'string') + queryCache.setQueryData( + generateOperationKey(['list'], { type: 'query', input: { from, to } }), + 'date', + ) + queryCache.setQueryData( + generateOperationKey(['list'], { type: 'query', input: { from: from.toISOString(), to } }), + 'string', + ) expect(queryCache.getEntries().length).toBe(2) - expect(queryCache.getEntries({ key: generateOperationKey(['list'], { input: { to } }) }).map(e => e.state.value.data)).toEqual(['date', 'string']) - expect(queryCache.getEntries({ key: generateOperationKey(['list'], { input: { from } }) }).map(e => e.state.value.data)).toEqual(['date']) + expect( + queryCache + .getEntries({ key: generateOperationKey(['list'], { input: { to } }) }) + .map((e) => e.state.value.data), + ).toEqual(['date', 'string']) + expect( + queryCache + .getEntries({ key: generateOperationKey(['list'], { input: { from } }) }) + .map((e) => e.state.value.data), + ).toEqual(['date']) }) }) describe('generateOperationKey serializer', () => { class Point { - constructor(public x: number, public y: number) {} + constructor( + public x: number, + public y: number, + ) {} } const serializer = new RPCJsonSerializer({ handlers: { point: { - condition: value => value instanceof Point, + condition: (value) => value instanceof Point, serialize: (value: Point) => [value.x, value.y], deserialize: ([x, y]: [number, number]) => new Point(x, y), isTerminal: true, @@ -94,33 +146,57 @@ describe('generateOperationKey serializer', () => { }) it('serializes input with the given serializer', () => { - expect(generateOperationKey(['path'], { serializer, input: { p: new Point(1, 2) } })).toEqual([['path'], { - input: { p: [1, 2] }, - inputMeta: { '["p"]': ['point'] }, - }]) - - expect(generateOperationKey(['path'], { input: { p: new Point(1, 2) } })).toEqual([['path'], { - input: { p: new Point(1, 2) }, - }]) + expect(generateOperationKey(['path'], { serializer, input: { p: new Point(1, 2) } })).toEqual([ + ['path'], + { + input: { p: [1, 2] }, + inputMeta: { '["p"]': ['point'] }, + }, + ]) + + expect(generateOperationKey(['path'], { input: { p: new Point(1, 2) } })).toEqual([ + ['path'], + { + input: { p: new Point(1, 2) }, + }, + ]) }) it('accepts an OpenAPI serializer, whose keys carry no type info', () => { - expect(generateOperationKey(['path'], { serializer: new OpenAPIJsonSerializer(), input: { a: new Date(0) } })).toEqual([['path'], { - input: { a: '1970-01-01T00:00:00.000Z' }, - }]) + expect( + generateOperationKey(['path'], { + serializer: new OpenAPIJsonSerializer(), + input: { a: new Date(0) }, + }), + ).toEqual([ + ['path'], + { + input: { a: '1970-01-01T00:00:00.000Z' }, + }, + ]) }) it('skips the serializer when input is undefined', () => { const serializeJson = vi.fn() - expect(generateOperationKey(['path'], { serializer: { serializeJson, deserializeJson: vi.fn() }, type: 'query' })).toEqual([['path'], { type: 'query' }]) + expect( + generateOperationKey(['path'], { + serializer: { serializeJson, deserializeJson: vi.fn() }, + type: 'query', + }), + ).toEqual([['path'], { type: 'query' }]) expect(serializeJson).not.toHaveBeenCalled() }) it('omits empty meta', () => { const serializeJson = vi.fn(() => ({ json: { a: 1 }, meta: [] })) - expect(generateOperationKey(['path'], { serializer: { serializeJson, deserializeJson: vi.fn() }, input: { a: 1 } })).toEqual([['path'], { input: { a: 1 } }]) + expect( + generateOperationKey(['path'], { + serializer: { serializeJson, deserializeJson: vi.fn() }, + input: { a: 1 }, + }), + ).toEqual([['path'], { input: { a: 1 } }]) expect(serializeJson).toHaveBeenCalledWith({ a: 1 }) }) }) diff --git a/packages/pinia-colada/src/key.ts b/packages/pinia-colada/src/key.ts index cf341ec9ae..31fd7ecf06 100644 --- a/packages/pinia-colada/src/key.ts +++ b/packages/pinia-colada/src/key.ts @@ -1,9 +1,10 @@ -import type { PartialDeep, Public } from '@orpc/shared' -import type { EntryKey } from '@pinia/colada' -import type { SerializableStreamedQueryOptions } from './stream-query' -import type { OperationType } from './types' import { RPCJsonSerializer } from '@orpc/client' +import type { PartialDeep, Public } from '@orpc/shared' import { stringifyJSON } from '@orpc/shared' +import type { EntryKey } from '@pinia/colada' + +import type { SerializableStreamedQueryOptions } from './stream-query.ts' +import type { OperationType } from './types.ts' export interface OperationKeyPrefixOptions { /** @@ -29,7 +30,10 @@ export type OperationKeyOptions = Operation fnOptions?: (TType extends 'streamed' ? SerializableStreamedQueryOptions : never) | undefined } -type OperationKeyContent = Omit, keyof OperationKeyBaseOptions> & { +type OperationKeyContent = Omit< + OperationKeyOptions, + keyof OperationKeyBaseOptions +> & { /** * Serializer types of non-JSON values in `input`, keyed by their JSON-encoded path, * so inputs with the same JSON form (like a `Date` and its ISO string) produce different keys. @@ -38,8 +42,8 @@ type OperationKeyContent = Omit | undefined } -export type OperationKey - = EntryKey & ( +export type OperationKey = EntryKey & + ( | [path: string[], options: OperationKeyContent] | [prefix: string, path: string[], options: OperationKeyContent] ) @@ -59,12 +63,14 @@ export function generateOperationKey( options: OperationKeyOptions = {}, ): OperationKey { return [ - ...options.prefix !== undefined ? [options.prefix] : [], + ...(options.prefix !== undefined ? [options.prefix] : []), path, { - ...options.input !== undefined ? serializeInput(options.serializer ?? defaultSerializer, options.input) : {}, - ...options.type !== undefined ? { type: options.type } : {}, - ...options.fnOptions !== undefined ? { fnOptions: options.fnOptions } : {}, + ...(options.input !== undefined + ? serializeInput(options.serializer ?? defaultSerializer, options.input) + : {}), + ...(options.type !== undefined ? { type: options.type } : {}), + ...(options.fnOptions !== undefined ? { fnOptions: options.fnOptions } : {}), }, ] as OperationKey } @@ -79,7 +85,7 @@ function serializeInput(serializer: Public, input: unknown) { const inputMeta: Record = {} for (const [type, ...segments] of meta) { - (inputMeta[stringifyJSON(segments)] ??= []).push(type) + ;(inputMeta[stringifyJSON(segments)] ??= []).push(type) } return { input: json, inputMeta } diff --git a/packages/pinia-colada/src/live-query.test.tsx b/packages/pinia-colada/src/live-query.test.tsx index 10a6db3fa5..a0071817ff 100644 --- a/packages/pinia-colada/src/live-query.test.tsx +++ b/packages/pinia-colada/src/live-query.test.tsx @@ -3,29 +3,30 @@ import { PiniaColada, useQuery, useQueryCache } from '@pinia/colada' import { mount } from '@vue/test-utils' import { createPinia } from 'pinia' import { defineComponent } from 'vue' -import { liveQuery } from './live-query' + +import { liveQuery } from './live-query.ts' /** * Mounts a component that runs a real Pinia Colada `useQuery`, * so tests can observe exactly what users see. */ function mountQuery(options: UseQueryOptions) { - return mount(defineComponent({ - setup() { - const queryCache = useQueryCache() - const query = useQuery(options) - - return { query, queryCache } + return mount( + defineComponent({ + setup() { + const queryCache = useQueryCache() + const query = useQuery(options) + + return { query, queryCache } + }, + render: () => null, + }), + { + global: { + plugins: [createPinia(), PiniaColada], + }, }, - render: () => null, - }), { - global: { - plugins: [ - createPinia(), - PiniaColada, - ], - }, - }) + ) } /** @@ -37,7 +38,7 @@ function createChunkController() { let notify: (() => void) | undefined return { - async* stream() { + async *stream() { while (true) { while (buffer.length === 0) { await new Promise((resolve) => { @@ -114,7 +115,9 @@ describe('liveQuery', () => { })() }) - await expect(queryFn({ signal: controller.signal } as any)).rejects.toBe(controller.signal.reason) + await expect(queryFn({ signal: controller.signal } as any)).rejects.toBe( + controller.signal.reason, + ) expect(started).not.toHaveBeenCalled() }) diff --git a/packages/pinia-colada/src/live-query.ts b/packages/pinia-colada/src/live-query.ts index 70ff2c0d23..e581bec976 100644 --- a/packages/pinia-colada/src/live-query.ts +++ b/packages/pinia-colada/src/live-query.ts @@ -1,15 +1,14 @@ import type { Promisable } from '@orpc/shared' -import type { UseQueryFnContext } from './types' import { stringifyJSON } from '@orpc/shared' +import type { UseQueryFnContext } from './types.ts' + /** * A Pinia Colada query function that publishes each streamed chunk to the * cache entry as it arrives, replacing the previous value. */ export function liveQuery( - queryFn: ( - context: UseQueryFnContext, - ) => Promisable>, + queryFn: (context: UseQueryFnContext) => Promisable>, ): (context: UseQueryFnContext) => Promise { return async (context) => { const { entry, signal } = context diff --git a/packages/pinia-colada/src/meta.test-d.ts b/packages/pinia-colada/src/meta.test-d.ts index ca7bf58138..c736737568 100644 --- a/packages/pinia-colada/src/meta.test-d.ts +++ b/packages/pinia-colada/src/meta.test-d.ts @@ -1,46 +1,50 @@ import type { ClientContext, ORPCError } from '@orpc/client' -import type { PromiseWithError } from '@orpc/shared' -import type { OperationContext } from './types' import { oc, type } from '@orpc/contract' -import { piniaColada } from './meta' +import type { PromiseWithError } from '@orpc/shared' + +import { piniaColada } from './meta.ts' +import type { OperationContext } from './types.ts' describe('piniaColada', () => { it('infers input, output and error types from the contract', () => { - oc - .errors({ BAD_GATEWAY: { data: type(vi.fn()) } }) + oc.errors({ BAD_GATEWAY: { data: type(vi.fn()) } }) .input(type(vi.fn())) .output(type(vi.fn())) - .meta(piniaColada({ - queryOptions: { staleTime: 1000 }, - queryInterceptors: [ - async ({ context, input, next }) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf() + .meta( + piniaColada({ + queryOptions: { staleTime: 1000 }, + queryInterceptors: [ + async ({ context, input, next }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf() - const result = next() + const result = next() - expectTypeOf(result).toEqualTypeOf< - PromiseWithError> - >() + expectTypeOf(result).toEqualTypeOf< + PromiseWithError> + >() - return result + return result + }, + ], + mutationOptions: { + onSuccess: (data, input) => { + expectTypeOf(data).toEqualTypeOf() + expectTypeOf(input).toEqualTypeOf() + }, }, - ], - mutationOptions: { - onSuccess: (data, input) => { - expectTypeOf(data).toEqualTypeOf() - expectTypeOf(input).toEqualTypeOf() - }, - }, - })) + }), + ) }) it('rejects invalid base options', () => { - oc.output(type(vi.fn())).meta(piniaColada({ - queryOptions: { - // @ts-expect-error - staleTime must be a number - staleTime: 'invalid', - }, - })) + oc.output(type(vi.fn())).meta( + piniaColada({ + queryOptions: { + // @ts-expect-error - staleTime must be a number + staleTime: 'invalid', + }, + }), + ) }) }) diff --git a/packages/pinia-colada/src/meta.test.ts b/packages/pinia-colada/src/meta.test.ts index 17d2dbbe9d..4946e0642f 100644 --- a/packages/pinia-colada/src/meta.test.ts +++ b/packages/pinia-colada/src/meta.test.ts @@ -1,14 +1,17 @@ import { oc } from '@orpc/contract' -import { ContractOptionsUtilsPlugin, getPiniaColadaMeta, piniaColada } from './meta' -import { createRouterUtils } from './router-utils' + +import { ContractOptionsUtilsPlugin, getPiniaColadaMeta, piniaColada } from './meta.ts' +import { createRouterUtils } from './router-utils.ts' describe('piniaColada', () => { it('stores options readable via getPiniaColadaMeta', () => { const interceptor = vi.fn() - const contract = oc.meta(piniaColada({ - queryOptions: { staleTime: 1000 }, - queryInterceptors: [interceptor], - })) + const contract = oc.meta( + piniaColada({ + queryOptions: { staleTime: 1000 }, + queryInterceptors: [interceptor], + }), + ) expect(getPiniaColadaMeta(contract)).toEqual({ queryOptions: { staleTime: 1000 }, @@ -24,15 +27,19 @@ describe('piniaColada', () => { const keyModifier = vi.fn() const contract = oc - .meta(piniaColada({ - queryKey: keyModifier, - queryOptions: { staleTime: 1000, gcTime: 1 }, - queryInterceptors: [interceptor1], - })) - .meta(piniaColada({ - queryOptions: { gcTime: 2 }, - mutationInterceptors: [interceptor2], - })) + .meta( + piniaColada({ + queryKey: keyModifier, + queryOptions: { staleTime: 1000, gcTime: 1 }, + queryInterceptors: [interceptor1], + }), + ) + .meta( + piniaColada({ + queryOptions: { gcTime: 2 }, + mutationInterceptors: [interceptor2], + }), + ) expect(getPiniaColadaMeta(contract)).toEqual({ queryKey: keyModifier, @@ -43,12 +50,10 @@ describe('piniaColada', () => { }) it('propagates base meta to procedures via .router', () => { - const router = oc - .meta(piniaColada({ queryOptions: { staleTime: 1000 } })) - .router({ - ping: oc.meta(piniaColada({ queryOptions: { gcTime: 2 } })), - pong: oc, - }) + const router = oc.meta(piniaColada({ queryOptions: { staleTime: 1000 } })).router({ + ping: oc.meta(piniaColada({ queryOptions: { gcTime: 2 } })), + pong: oc, + }) expect(getPiniaColadaMeta(router.ping)).toEqual({ queryOptions: { staleTime: 1000, gcTime: 2 }, @@ -76,10 +81,12 @@ describe('contractOptionsUtilsPlugin', () => { const plugin = new ContractOptionsUtilsPlugin({ planet: { - find: oc.meta(piniaColada({ - queryOptions: { staleTime: 1000, gcTime: 1 }, - queryInterceptors: [metaInterceptor], - })), + find: oc.meta( + piniaColada({ + queryOptions: { staleTime: 1000, gcTime: 1 }, + queryInterceptors: [metaInterceptor], + }), + ), }, }) @@ -109,10 +116,12 @@ describe('contractOptionsUtilsPlugin', () => { const contract = { planet: { - find: oc.meta(piniaColada({ - queryOptions: { staleTime: 1000, gcTime: 1 }, - queryInterceptors: [metaInterceptor], - })), + find: oc.meta( + piniaColada({ + queryOptions: { staleTime: 1000, gcTime: 1 }, + queryInterceptors: [metaInterceptor], + }), + ), }, } @@ -148,9 +157,11 @@ describe('contractOptionsUtilsPlugin', () => { it('lets object utils options beat defaults of a function meta modifier', () => { const contract = { planet: { - find: oc.meta(piniaColada({ - queryOptions: (options: any) => ({ staleTime: 1000, gcTime: 1, ...options }), - })), + find: oc.meta( + piniaColada({ + queryOptions: (options: any) => ({ staleTime: 1000, gcTime: 1, ...options }), + }), + ), }, } @@ -177,20 +188,26 @@ describe('contractOptionsUtilsPlugin', () => { it('keeps contract meta interceptors when utils define none', async () => { const metaInterceptor = vi.fn(({ next }: any) => next()) - const contract = { planet: { find: oc.meta(piniaColada({ queryInterceptors: [metaInterceptor] })) } } + const contract = { + planet: { find: oc.meta(piniaColada({ queryInterceptors: [metaInterceptor] })) }, + } const client = { planet: { find: vi.fn(async () => '__found__') } } const utils = createRouterUtils(client as any, { plugins: [new ContractOptionsUtilsPlugin(contract)], }) as any - await expect(utils.planet.find.queryOptions({ input: { id: 1 } }).query({ signal: undefined })).resolves.toBe('__found__') + await expect( + utils.planet.find.queryOptions({ input: { id: 1 } }).query({ signal: undefined }), + ).resolves.toBe('__found__') expect(metaInterceptor).toHaveBeenCalledTimes(1) }) it('resets contract meta interceptors when utils set them to undefined explicitly', async () => { const metaInterceptor = vi.fn(({ next }: any) => next()) - const contract = { planet: { find: oc.meta(piniaColada({ queryInterceptors: [metaInterceptor] })) } } + const contract = { + planet: { find: oc.meta(piniaColada({ queryInterceptors: [metaInterceptor] })) }, + } const client = { planet: { find: vi.fn(async () => '__found__') } } const utils = createRouterUtils(client as any, { @@ -198,7 +215,9 @@ describe('contractOptionsUtilsPlugin', () => { plugins: [new ContractOptionsUtilsPlugin(contract)], }) as any - await expect(utils.planet.find.queryOptions({ input: { id: 1 } }).query({ signal: undefined })).resolves.toBe('__found__') + await expect( + utils.planet.find.queryOptions({ input: { id: 1 } }).query({ signal: undefined }), + ).resolves.toBe('__found__') expect(metaInterceptor).not.toHaveBeenCalled() }) }) diff --git a/packages/pinia-colada/src/meta.ts b/packages/pinia-colada/src/meta.ts index 9aeec5e55d..1a31e15d38 100644 --- a/packages/pinia-colada/src/meta.ts +++ b/packages/pinia-colada/src/meta.ts @@ -1,14 +1,36 @@ -import type { AnyNestedClient, ClientContext, InferClientContext, InferClientError } from '@orpc/client' -import type { AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, Meta, MetaPlugin, ORPCErrorFromErrorMap, RouterContract } from '@orpc/contract' -import type { ThrowableError } from '@orpc/shared' -import type { OperationKeyBaseOptions } from './key' -import type { RouterUtilsPlugin } from './plugin' -import type { ProcedureUtilsOptions } from './procedure-utils' +import type { + AnyNestedClient, + ClientContext, + InferClientContext, + InferClientError, +} from '@orpc/client' +import type { + AnySchema, + ErrorMap, + InferSchemaInput, + InferSchemaOutput, + Meta, + MetaPlugin, + ORPCErrorFromErrorMap, + RouterContract, +} from '@orpc/contract' import { getRouterContract, ProcedureContract } from '@orpc/contract' -import { mergeProcedureUtilsOptions } from './procedure-utils' +import type { ThrowableError } from '@orpc/shared' + +import type { OperationKeyBaseOptions } from './key.ts' +import type { RouterUtilsPlugin } from './plugin.ts' +import type { ProcedureUtilsOptions } from './procedure-utils.ts' +import { mergeProcedureUtilsOptions } from './procedure-utils.ts' -export type PiniaColadaMetaOptions - = Omit, keyof OperationKeyBaseOptions> +export type PiniaColadaMetaOptions< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> = Omit< + ProcedureUtilsOptions, + keyof OperationKeyBaseOptions +> export interface PiniaColadaMetaPlugin< TInputSchema extends AnySchema, @@ -43,7 +65,9 @@ export function piniaColada< return { name: '~pinia-colada', init(meta) { - const current = meta['~pinia-colada'] as ProcedureUtilsOptions | undefined + const current = meta['~pinia-colada'] as + | ProcedureUtilsOptions + | undefined return { ...meta, @@ -53,10 +77,12 @@ export function piniaColada< } } -export function getPiniaColadaMeta( - procedureOrLazy: { '~orpc': { meta: Meta } }, -): PiniaColadaMetaOptions | undefined { - return procedureOrLazy['~orpc'].meta['~pinia-colada'] as PiniaColadaMetaOptions | undefined +export function getPiniaColadaMeta(procedureOrLazy: { + '~orpc': { meta: Meta } +}): PiniaColadaMetaOptions | undefined { + return procedureOrLazy['~orpc'].meta['~pinia-colada'] as + | PiniaColadaMetaOptions + | undefined } /** @@ -69,12 +95,12 @@ export function getPiniaColadaMeta( * * @see {@link https://orpc.dev/docs/integrations/pinia-colada#contract-options-plugin | Pinia Colada Integration - Contract Options Plugin} */ -export class ContractOptionsUtilsPlugin implements RouterUtilsPlugin { +export class ContractOptionsUtilsPlugin< + T extends AnyNestedClient = AnyNestedClient, +> implements RouterUtilsPlugin { readonly name = '~contract-options' - constructor( - private readonly contract: RouterContract, - ) {} + constructor(private readonly contract: RouterContract) {} initProcedureOptions( path: string[], diff --git a/packages/pinia-colada/src/plugin.test.ts b/packages/pinia-colada/src/plugin.test.ts index 6b0d90e925..8ef2108184 100644 --- a/packages/pinia-colada/src/plugin.test.ts +++ b/packages/pinia-colada/src/plugin.test.ts @@ -1,5 +1,5 @@ -import type { RouterUtilsPlugin } from './plugin' -import { CompositeRouterUtilsPlugin } from './plugin' +import type { RouterUtilsPlugin } from './plugin.ts' +import { CompositeRouterUtilsPlugin } from './plugin.ts' describe('compositeRouterUtilsPlugin', () => { beforeEach(() => { @@ -78,8 +78,14 @@ describe('compositeRouterUtilsPlugin', () => { expect(plugin3.initProcedureOptions).toHaveBeenCalledOnce() expect(plugin3.initProcedureOptions).toHaveBeenCalledWith(['key'], { marks: [] }) - expect(plugin2.initProcedureOptions).toHaveBeenCalledWith(['key'], { path: ['key'], marks: ['3'] }) - expect(plugin1.initProcedureOptions).toHaveBeenCalledWith(['key'], { path: ['key'], marks: ['3', '2'] }) + expect(plugin2.initProcedureOptions).toHaveBeenCalledWith(['key'], { + path: ['key'], + marks: ['3'], + }) + expect(plugin1.initProcedureOptions).toHaveBeenCalledWith(['key'], { + path: ['key'], + marks: ['3', '2'], + }) expect(result).toEqual({ path: ['key'], marks: ['3', '2', '1'] }) }) diff --git a/packages/pinia-colada/src/plugin.ts b/packages/pinia-colada/src/plugin.ts index fbe7986061..ec82fc4d90 100644 --- a/packages/pinia-colada/src/plugin.ts +++ b/packages/pinia-colada/src/plugin.ts @@ -1,9 +1,10 @@ import type { AnyNestedClient, InferClientContext, InferClientError } from '@orpc/client' import type { OrderablePlugin } from '@orpc/shared' -import type { ProcedureUtilsOptions } from './procedure-utils' -import type { RouterUtilsOptions } from './router-utils' import { sortPlugins } from '@orpc/shared' +import type { ProcedureUtilsOptions } from './procedure-utils.ts' +import type { RouterUtilsOptions } from './router-utils.ts' + export interface RouterUtilsPlugin extends OrderablePlugin { /** * Initializes the router utils plugin and returns updated router utils options. @@ -47,9 +48,7 @@ export interface RouterUtilsPlugin extends OrderableP export class CompositeRouterUtilsPlugin implements RouterUtilsPlugin { readonly name = '~composite' - constructor( - protected readonly plugins: RouterUtilsPlugin[] = [], - ) { + constructor(protected readonly plugins: RouterUtilsPlugin[] = []) { this.plugins = sortPlugins(plugins) } diff --git a/packages/pinia-colada/src/procedure-utils.test-d.ts b/packages/pinia-colada/src/procedure-utils.test-d.ts index 611eac707a..ffb351c328 100644 --- a/packages/pinia-colada/src/procedure-utils.test-d.ts +++ b/packages/pinia-colada/src/procedure-utils.test-d.ts @@ -2,13 +2,24 @@ import type { Client, ORPCError } from '@orpc/client' import type { ORPCErrorFromErrorMap } from '@orpc/contract' import type { PromiseWithError, Public } from '@orpc/shared' import type { UseInfiniteQueryData, UseInfiniteQueryFnContext } from '@pinia/colada' -import type { ProcedureUtils, ProcedureUtilsOptions } from './procedure-utils' -import type { InfiniteOptionsIn, MutationKeyOptions, MutationOptionsIn, QueryKeyOptions, QueryOptionsIn, StreamedKeyOptions, StreamedOptionsIn, UseMutationFnContext, UseQueryFnContext } from './types' import { useInfiniteQuery, useMutation, useQuery, useQueryCache } from '@pinia/colada' import { computed } from 'vue' -import z from 'zod' - -const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) +import * as z from 'zod' + +import type { ProcedureUtils, ProcedureUtilsOptions } from './procedure-utils.ts' +import type { + InfiniteOptionsIn, + MutationKeyOptions, + MutationOptionsIn, + QueryKeyOptions, + QueryOptionsIn, + StreamedKeyOptions, + StreamedOptionsIn, + UseMutationFnContext, + UseQueryFnContext, +} from './types.ts' + +const outputSchema = z.object({ output: z.number().transform((n) => `${n}`) }) const baseErrorMap = { BASE: { @@ -18,25 +29,17 @@ const baseErrorMap = { } describe('ProcedureUtils', () => { - type UtilsInput = { search?: string, cursor?: number } | undefined + type UtilsInput = { search?: string; cursor?: number } | undefined type UtilsOutput = { title: string }[] type UtilsError = ORPCErrorFromErrorMap - const utils = {} as Public> + const utils = {} as Public< + ProcedureUtils<{ batch?: boolean }, UtilsInput, UtilsOutput, UtilsError> + > it('.call', () => { expectTypeOf(utils.call).toEqualTypeOf< - Client< - { batch?: boolean }, - UtilsInput, - UtilsOutput, - UtilsError - > + Client<{ batch?: boolean }, UtilsInput, UtilsOutput, UtilsError> >() }) @@ -55,7 +58,9 @@ describe('ProcedureUtils', () => { // @ts-expect-error invalid input utils.queryKey({ input: 'invalid' }) - const requiredUtils = {} as Public> + const requiredUtils = {} as Public< + ProcedureUtils<{ batch?: boolean }, 'input', UtilsOutput, Error> + > requiredUtils.queryKey({ input: 'input' }) // @ts-expect-error input is required requiredUtils.queryKey() @@ -63,18 +68,22 @@ describe('ProcedureUtils', () => { }) describe('.streamedOptions & .liveOptions', () => { - const streamUtils = {} as Public, - UtilsError - >> + const streamUtils = {} as Public< + ProcedureUtils< + { batch?: boolean }, + UtilsInput, + AsyncIterable, + UtilsError + > + > it('works with useQuery', () => { - const streamed = useQuery(streamUtils.streamedOptions({ - input: { search: 'search' }, - fnOptions: { refetchMode: 'append', maxChunks: 3 }, - })) + const streamed = useQuery( + streamUtils.streamedOptions({ + input: { search: 'search' }, + fnOptions: { refetchMode: 'append', maxChunks: 3 }, + }), + ) expectTypeOf(streamed.data.value).toEqualTypeOf() expectTypeOf(streamed.error.value).toEqualTypeOf() @@ -112,7 +121,9 @@ describe('ProcedureUtils', () => { const queryCache = useQueryCache() expectTypeOf( - queryCache.getQueryData(utils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 0 })), + queryCache.getQueryData( + utils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 0 }), + ), ).toEqualTypeOf | undefined>() utils.infiniteKey({ key: ['__custom__'] }) @@ -135,13 +146,15 @@ describe('ProcedureUtils', () => { utils.mutationKey({}) utils.mutationKey({ key: ['__custom__'] }) - utils.mutationKey({ key: input => [{ input }] as any }) + utils.mutationKey({ key: (input) => [{ input }] as any }) }) }) describe('.queryOptions', () => { it('can optional options', () => { - const requiredUtils = {} as Public> + const requiredUtils = {} as Public< + ProcedureUtils<{ batch?: boolean }, 'input', UtilsOutput, Error> + > utils.queryOptions() utils.queryOptions({ context: { batch: true } }) @@ -194,9 +207,11 @@ describe('ProcedureUtils', () => { }) it('with initial data', () => { - const query = useQuery(utils.queryOptions({ - initialData: () => [{ title: 'title' }], - })) + const query = useQuery( + utils.queryOptions({ + initialData: () => [{ title: 'title' }], + }), + ) expectTypeOf(query.data.value).toEqualTypeOf() expectTypeOf(query.error.value).toEqualTypeOf() @@ -246,21 +261,25 @@ describe('ProcedureUtils', () => { }) it('works with useInfiniteQuery', () => { - const query = useInfiniteQuery(utils.infiniteOptions({ - input: (cursor: number) => ({ cursor }), - initialPageParam: 0, - getNextPageParam: () => 1, - })) + const query = useInfiniteQuery( + utils.infiniteOptions({ + input: (cursor: number) => ({ cursor }), + initialPageParam: 0, + getNextPageParam: () => 1, + }), + ) expectTypeOf(query.data.value?.pages).toEqualTypeOf() expectTypeOf(query.data.value?.pageParams).toEqualTypeOf() expectTypeOf(query.error.value).toEqualTypeOf() - const lazyQuery = useInfiniteQuery(() => utils.infiniteOptions({ - input: (cursor: number) => ({ cursor }), - initialPageParam: 0, - getNextPageParam: () => 1, - })) + const lazyQuery = useInfiniteQuery(() => + utils.infiniteOptions({ + input: (cursor: number) => ({ cursor }), + initialPageParam: 0, + getNextPageParam: () => 1, + }), + ) expectTypeOf(lazyQuery.data.value?.pages).toEqualTypeOf() }) @@ -268,7 +287,9 @@ describe('ProcedureUtils', () => { describe('.mutationOptions', () => { it('can optional options', () => { - const requiredUtils = {} as Public> + const requiredUtils = {} as Public< + ProcedureUtils<{ batch: boolean }, 'input', UtilsOutput, Error> + > utils.mutationOptions() utils.mutationOptions({}) @@ -296,15 +317,17 @@ describe('ProcedureUtils', () => { }) it('works with useMutation', () => { - const mutation = useMutation(utils.mutationOptions({ - onSuccess: (data, input) => { - expectTypeOf(data).toEqualTypeOf() - expectTypeOf(input).toEqualTypeOf() - }, - onError: (error) => { - expectTypeOf(error).toEqualTypeOf() - }, - })) + const mutation = useMutation( + utils.mutationOptions({ + onSuccess: (data, input) => { + expectTypeOf(data).toEqualTypeOf() + expectTypeOf(input).toEqualTypeOf() + }, + onError: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + }), + ) expectTypeOf[0]>().toEqualTypeOf() expectTypeOf(mutation.data.value).toEqualTypeOf() @@ -336,7 +359,12 @@ describe('ProcedureUtilsOptions', () => { type TestOptions = ProcedureUtilsOptions type TestStreamedOutput = AsyncIterable - type TestStreamedOptions = ProcedureUtilsOptions + type TestStreamedOptions = ProcedureUtilsOptions< + TestClientContext, + TestInput, + TestStreamedOutput, + TestError + > it('should have all keys that ProcedureUtils provides', () => { // Ensures every utility method in ProcedureUtils (except 'call') has a corresponding key in ProcedureUtilsOptions @@ -419,7 +447,15 @@ describe('ProcedureUtilsOptions', () => { const _defaults2: TestOptions = { queryOptions: (options) => { - expectTypeOf(options).toEqualTypeOf>() + expectTypeOf(options).toEqualTypeOf< + QueryOptionsIn< + TestClientContext, + TestInput, + TestOutput, + TestError, + TestOutput | undefined + > + >() return { ...options, @@ -432,7 +468,7 @@ describe('ProcedureUtilsOptions', () => { const _invalid2: TestOptions = { // @ts-expect-error - invalid input type - queryOptions: options => ({ + queryOptions: (options) => ({ ...options, input: { invalid: 'test' }, }), @@ -504,7 +540,9 @@ describe('ProcedureUtilsOptions', () => { const _defaults2: TestOptions = { streamedOptions: (options) => { - expectTypeOf(options).toEqualTypeOf>() + expectTypeOf(options).toEqualTypeOf< + StreamedOptionsIn + >() return { ...options, @@ -516,7 +554,7 @@ describe('ProcedureUtilsOptions', () => { const _invalid2: TestOptions = { // @ts-expect-error - invalid input type - streamedOptions: options => ({ + streamedOptions: (options) => ({ ...options, input: { invalid: 'test' }, }), @@ -549,7 +587,7 @@ describe('ProcedureUtilsOptions', () => { const _invalid2: TestOptions = { // @ts-expect-error - invalid input type - liveKey: options => ({ + liveKey: (options) => ({ ...options, input: { invalid: 'test' }, }), @@ -587,7 +625,9 @@ describe('ProcedureUtilsOptions', () => { const _defaults2: TestOptions = { liveOptions: (options) => { - expectTypeOf(options).toEqualTypeOf>() + expectTypeOf(options).toEqualTypeOf< + QueryOptionsIn + >() return { ...options, @@ -599,7 +639,7 @@ describe('ProcedureUtilsOptions', () => { const _invalid2: TestOptions = { // @ts-expect-error - invalid input type - liveOptions: options => ({ + liveOptions: (options) => ({ ...options, input: { invalid: 'test' }, }), @@ -633,7 +673,9 @@ describe('ProcedureUtilsOptions', () => { (opts) => { expectTypeOf(opts.input).toEqualTypeOf() expectTypeOf(opts.next()).toEqualTypeOf>() - expectTypeOf(opts.fnContext).toEqualTypeOf>() + expectTypeOf(opts.fnContext).toEqualTypeOf< + UseInfiniteQueryFnContext + >() return opts.next() }, @@ -657,7 +699,16 @@ describe('ProcedureUtilsOptions', () => { const _defaults2: TestOptions = { infiniteOptions: (options) => { - expectTypeOf(options).toEqualTypeOf | undefined>>() + expectTypeOf(options).toEqualTypeOf< + InfiniteOptionsIn< + TestClientContext, + TestInput, + TestOutput, + TestError, + unknown, + UseInfiniteQueryData | undefined + > + >() return { ...options, @@ -668,7 +719,7 @@ describe('ProcedureUtilsOptions', () => { const _invalid2: TestOptions = { // @ts-expect-error - invalid input type - infiniteOptions: options => ({ + infiniteOptions: (options) => ({ ...options, input: { invalid: 'test' }, }), @@ -701,7 +752,7 @@ describe('ProcedureUtilsOptions', () => { const _invalid2: TestOptions = { // @ts-expect-error - invalid key type - mutationKey: options => ({ + mutationKey: (options) => ({ key: 1, }), } @@ -740,7 +791,9 @@ describe('ProcedureUtilsOptions', () => { const _defaults2: TestOptions = { mutationOptions: (options) => { - expectTypeOf(options).toEqualTypeOf>>() + expectTypeOf(options).toEqualTypeOf< + MutationOptionsIn> + >() return { ...options, @@ -754,7 +807,7 @@ describe('ProcedureUtilsOptions', () => { const _invalid2: TestOptions = { // @ts-expect-error - invalid context type - mutationOptions: options => ({ + mutationOptions: (options) => ({ ...options, context: { batch: 'invalid' }, }), diff --git a/packages/pinia-colada/src/procedure-utils.test.ts b/packages/pinia-colada/src/procedure-utils.test.ts index ac7cc9dc80..5e8032bdf9 100644 --- a/packages/pinia-colada/src/procedure-utils.test.ts +++ b/packages/pinia-colada/src/procedure-utils.test.ts @@ -1,9 +1,14 @@ import { RPCJsonSerializer } from '@orpc/client' -import * as KeyModule from './key' -import * as LiveQueryModule from './live-query' -import { isProcedureUtilsOptions, mergeProcedureUtilsOptions, ProcedureUtils } from './procedure-utils' -import * as StreamQueryModule from './stream-query' -import { OPERATION_CONTEXT_SYMBOL } from './types' + +import * as KeyModule from './key.ts' +import * as LiveQueryModule from './live-query.ts' +import { + isProcedureUtilsOptions, + mergeProcedureUtilsOptions, + ProcedureUtils, +} from './procedure-utils.ts' +import * as StreamQueryModule from './stream-query.ts' +import { OPERATION_CONTEXT_SYMBOL } from './types.ts' const generateOperationKeySpy = vi.spyOn(KeyModule, 'generateOperationKey') const streamedQuerySpy = vi.spyOn(StreamQueryModule, 'serializableStreamedQuery') @@ -25,12 +30,20 @@ describe('procedureUtils', () => { describe('.queryKey', () => { it('works', () => { - expect(utils.queryKey({ input: { search: '__search__' } } as any)).toBe(generateOperationKeySpy.mock.results[0]!.value) + expect(utils.queryKey({ input: { search: '__search__' } } as any)).toBe( + generateOperationKeySpy.mock.results[0]!.value, + ) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { type: 'query', input: { search: '__search__' } }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + type: 'query', + input: { search: '__search__' }, + }) expect(utils.queryKey()).toBe(generateOperationKeySpy.mock.results[1]!.value) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { type: 'query', input: undefined }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { + type: 'query', + input: undefined, + }) expect(utils.queryKey({ key: ['__custom__'] } as any)).toEqual(['__custom__']) expect(generateOperationKeySpy).toHaveBeenCalledTimes(2) @@ -42,10 +55,16 @@ describe('procedureUtils', () => { }) modifiedUtils.queryKey() - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { type: 'query', input: 1 }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { + type: 'query', + input: 1, + }) modifiedUtils.queryKey({ input: 2 } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { type: 'query', input: 2 }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { + type: 'query', + input: 2, + }) }) it('applies function modifier', () => { @@ -63,9 +82,19 @@ describe('procedureUtils', () => { describe('.streamedKey', () => { it('works', () => { - expect(utils.streamedKey({ input: { search: '__search__' }, fnOptions: { refetchMode: 'append' } } as any)).toBe(generateOperationKeySpy.mock.results[0]!.value) + expect( + utils.streamedKey({ + input: { search: '__search__' }, + fnOptions: { refetchMode: 'append' }, + } as any), + ).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { prefix: undefined, type: 'streamed', input: { search: '__search__' }, fnOptions: { refetchMode: 'append' } }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + prefix: undefined, + type: 'streamed', + input: { search: '__search__' }, + fnOptions: { refetchMode: 'append' }, + }) expect(utils.streamedKey({ key: ['__custom__'] } as any)).toEqual(['__custom__']) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) @@ -77,10 +106,20 @@ describe('procedureUtils', () => { }) modifiedUtils.streamedKey() - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { prefix: undefined, type: 'streamed', input: undefined, fnOptions: { maxChunks: 1 } }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { + prefix: undefined, + type: 'streamed', + input: undefined, + fnOptions: { maxChunks: 1 }, + }) modifiedUtils.streamedKey({ fnOptions: { maxChunks: 2 } } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { prefix: undefined, type: 'streamed', input: undefined, fnOptions: { maxChunks: 2 } }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { + prefix: undefined, + type: 'streamed', + input: undefined, + fnOptions: { maxChunks: 2 }, + }) }) it('applies function modifier', () => { @@ -92,12 +131,20 @@ describe('procedureUtils', () => { modifiedUtils.streamedKey({ input: 1 } as any) expect(modifier).toHaveBeenCalledTimes(1) expect(modifier).toHaveBeenCalledWith({ input: 1 }) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { prefix: undefined, type: 'streamed', input: 9, fnOptions: undefined }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + prefix: undefined, + type: 'streamed', + input: 9, + fnOptions: undefined, + }) }) }) describe('.streamedOptions', () => { - const entry = { state: { value: { status: 'pending', data: undefined, error: null } }, key: ['test'] } + const entry = { + state: { value: { status: 'pending', data: undefined, error: null } }, + key: ['test'], + } async function* stream(...values: any[]) { for (const value of values) { @@ -110,7 +157,12 @@ describe('procedureUtils', () => { expect(options.key).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { prefix: undefined, type: 'streamed', input: 1, fnOptions: { maxChunks: 3 } }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + prefix: undefined, + type: 'streamed', + input: 1, + fnOptions: { maxChunks: 3 }, + }) expect(options.fnOptions).toBeUndefined() // consumed, not forwarded to pinia colada client.mockResolvedValueOnce(stream('a', 'b')) @@ -119,24 +171,31 @@ describe('procedureUtils', () => { expect(streamedQuerySpy).toHaveBeenCalledTimes(1) expect(streamedQuerySpy).toHaveBeenCalledWith(expect.any(Function), { maxChunks: 3 }) expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith(1, { signal, context: { - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'streamed', + expect(client).toHaveBeenCalledWith(1, { + signal, + context: { + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'streamed', + }, }, - } }) + }) }) it('throws when output is not an AsyncIteratorObject', async () => { const options = utils.streamedOptions({ input: 1 } as any) as any client.mockResolvedValueOnce('__not_iterable__') - await expect(options.query({ signal, entry })).rejects.toThrow('streamedQuery requires an AsyncIteratorObject output') + await expect(options.query({ signal, entry })).rejects.toThrow( + 'streamedQuery requires an AsyncIteratorObject output', + ) }) it('uses custom query instead of client but still runs interceptors', async () => { const interceptor = vi.fn(({ next }) => next()) - const interceptedUtils = new ProcedureUtils(['ping'], client, { streamedInterceptors: [interceptor] }) + const interceptedUtils = new ProcedureUtils(['ping'], client, { + streamedInterceptors: [interceptor], + }) const query = vi.fn().mockResolvedValue(['__custom__']) const fnContext = { signal, entry } as any @@ -151,26 +210,48 @@ describe('procedureUtils', () => { it('runs interceptors in order & allows overriding options', async () => { const interceptor1 = vi.fn(({ next }) => next()) - const interceptor2 = vi.fn(options => options.next({ - ...options, - input: '__override__', - })) + const interceptor2 = vi.fn((options) => + options.next({ + ...options, + input: '__override__', + }), + ) const interceptedUtils = new ProcedureUtils(['ping'], client, { streamedInterceptors: [interceptor1, interceptor2], }) - const options = interceptedUtils.streamedOptions({ input: 1, context: { batch: true } } as any) as any + const options = interceptedUtils.streamedOptions({ + input: 1, + context: { batch: true }, + } as any) as any const fnContext = { signal, entry } as any client.mockResolvedValueOnce(stream('a')) await expect(options.query(fnContext)).resolves.toEqual(['a']) expect(interceptor1).toHaveBeenCalledTimes(1) - expect(interceptor1).toHaveBeenCalledWith(expect.objectContaining({ - path: ['ping'], - input: 1, - fnContext, + expect(interceptor1).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['ping'], + input: 1, + fnContext, + context: { + batch: true, + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'streamed', + }, + }, + }), + ) + expect(interceptor2).toHaveBeenCalledTimes(1) + expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan( + interceptor2.mock.invocationCallOrder[0]!, + ) + + expect(client).toHaveBeenCalledWith('__override__', { + signal, context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { @@ -178,17 +259,7 @@ describe('procedureUtils', () => { type: 'streamed', }, }, - })) - expect(interceptor2).toHaveBeenCalledTimes(1) - expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan(interceptor2.mock.invocationCallOrder[0]!) - - expect(client).toHaveBeenCalledWith('__override__', { signal, context: { - batch: true, - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'streamed', - }, - } }) + }) }) it('applies object modifier with per-call options taking precedence', () => { @@ -218,9 +289,15 @@ describe('procedureUtils', () => { describe('.liveKey', () => { it('works', () => { - expect(utils.liveKey({ input: { search: '__search__' } } as any)).toBe(generateOperationKeySpy.mock.results[0]!.value) + expect(utils.liveKey({ input: { search: '__search__' } } as any)).toBe( + generateOperationKeySpy.mock.results[0]!.value, + ) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { prefix: undefined, type: 'live', input: { search: '__search__' } }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + prefix: undefined, + type: 'live', + input: { search: '__search__' }, + }) expect(utils.liveKey({ key: ['__custom__'] } as any)).toEqual(['__custom__']) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) @@ -232,10 +309,18 @@ describe('procedureUtils', () => { }) modifiedUtils.liveKey() - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { prefix: undefined, type: 'live', input: 1 }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { + prefix: undefined, + type: 'live', + input: 1, + }) modifiedUtils.liveKey({ input: 2 } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { prefix: undefined, type: 'live', input: 2 }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { + prefix: undefined, + type: 'live', + input: 2, + }) }) it('applies function modifier', () => { @@ -247,12 +332,19 @@ describe('procedureUtils', () => { modifiedUtils.liveKey({ input: 1 } as any) expect(modifier).toHaveBeenCalledTimes(1) expect(modifier).toHaveBeenCalledWith({ input: 1 }) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { prefix: undefined, type: 'live', input: 9 }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + prefix: undefined, + type: 'live', + input: 9, + }) }) }) describe('.liveOptions', () => { - const entry = { state: { value: { status: 'pending', data: undefined, error: null } }, key: ['test'] } + const entry = { + state: { value: { status: 'pending', data: undefined, error: null } }, + key: ['test'], + } async function* stream(...values: any[]) { for (const value of values) { @@ -265,31 +357,42 @@ describe('procedureUtils', () => { expect(options.key).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { prefix: undefined, type: 'live', input: 1 }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + prefix: undefined, + type: 'live', + input: 1, + }) client.mockResolvedValueOnce(stream('a', 'b')) await expect(options.query({ signal, entry })).resolves.toEqual('b') expect(liveQuerySpy).toHaveBeenCalledTimes(1) expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith(1, { signal, context: { - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'live', + expect(client).toHaveBeenCalledWith(1, { + signal, + context: { + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'live', + }, }, - } }) + }) }) it('throws when output is not an AsyncIteratorObject', async () => { const options = utils.liveOptions({ input: 1 } as any) as any client.mockResolvedValueOnce('__not_iterable__') - await expect(options.query({ signal, entry })).rejects.toThrow('liveQuery requires an AsyncIteratorObject output') + await expect(options.query({ signal, entry })).rejects.toThrow( + 'liveQuery requires an AsyncIteratorObject output', + ) }) it('uses custom query instead of client but still runs interceptors', async () => { const interceptor = vi.fn(({ next }) => next()) - const interceptedUtils = new ProcedureUtils(['ping'], client, { liveInterceptors: [interceptor] }) + const interceptedUtils = new ProcedureUtils(['ping'], client, { + liveInterceptors: [interceptor], + }) const query = vi.fn().mockResolvedValue('__custom__') const fnContext = { signal, entry } as any @@ -304,26 +407,48 @@ describe('procedureUtils', () => { it('runs interceptors in order & allows overriding options', async () => { const interceptor1 = vi.fn(({ next }) => next()) - const interceptor2 = vi.fn(options => options.next({ - ...options, - input: '__override__', - })) + const interceptor2 = vi.fn((options) => + options.next({ + ...options, + input: '__override__', + }), + ) const interceptedUtils = new ProcedureUtils(['ping'], client, { liveInterceptors: [interceptor1, interceptor2], }) - const options = interceptedUtils.liveOptions({ input: 1, context: { batch: true } } as any) as any + const options = interceptedUtils.liveOptions({ + input: 1, + context: { batch: true }, + } as any) as any const fnContext = { signal, entry } as any client.mockResolvedValueOnce(stream('a')) await expect(options.query(fnContext)).resolves.toEqual('a') expect(interceptor1).toHaveBeenCalledTimes(1) - expect(interceptor1).toHaveBeenCalledWith(expect.objectContaining({ - path: ['ping'], - input: 1, - fnContext, + expect(interceptor1).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['ping'], + input: 1, + fnContext, + context: { + batch: true, + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'live', + }, + }, + }), + ) + expect(interceptor2).toHaveBeenCalledTimes(1) + expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan( + interceptor2.mock.invocationCallOrder[0]!, + ) + + expect(client).toHaveBeenCalledWith('__override__', { + signal, context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { @@ -331,17 +456,7 @@ describe('procedureUtils', () => { type: 'live', }, }, - })) - expect(interceptor2).toHaveBeenCalledTimes(1) - expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan(interceptor2.mock.invocationCallOrder[0]!) - - expect(client).toHaveBeenCalledWith('__override__', { signal, context: { - batch: true, - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'live', - }, - } }) + }) }) it('applies object modifier with per-call options taking precedence', () => { @@ -371,12 +486,25 @@ describe('procedureUtils', () => { describe('.infiniteKey', () => { it('works', () => { - expect(utils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 0 } as any)).toBe(generateOperationKeySpy.mock.results[0]!.value) + expect( + utils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 0 } as any), + ).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { type: 'infinite', input: { cursor: 0 } }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + type: 'infinite', + input: { cursor: 0 }, + }) - expect(utils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: () => 5 } as any)).toBe(generateOperationKeySpy.mock.results[1]!.value) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { type: 'infinite', input: { cursor: 5 } }) + expect( + utils.infiniteKey({ + input: (cursor: number) => ({ cursor }), + initialPageParam: () => 5, + } as any), + ).toBe(generateOperationKeySpy.mock.results[1]!.value) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { + type: 'infinite', + input: { cursor: 5 }, + }) expect(utils.infiniteKey({ key: ['__custom__'] } as any)).toEqual(['__custom__']) expect(generateOperationKeySpy).toHaveBeenCalledTimes(2) @@ -388,10 +516,19 @@ describe('procedureUtils', () => { }) modifiedUtils.infiniteKey({ input: (cursor: number) => ({ cursor }) } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { type: 'infinite', input: { cursor: 1 } }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { + type: 'infinite', + input: { cursor: 1 }, + }) - modifiedUtils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 2 } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { type: 'infinite', input: { cursor: 2 } }) + modifiedUtils.infiniteKey({ + input: (cursor: number) => ({ cursor }), + initialPageParam: 2, + } as any) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { + type: 'infinite', + input: { cursor: 2 }, + }) }) it('applies function modifier', () => { @@ -400,9 +537,15 @@ describe('procedureUtils', () => { infiniteKey: modifier as any, }) - modifiedUtils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 1 } as any) + modifiedUtils.infiniteKey({ + input: (cursor: number) => ({ cursor }), + initialPageParam: 1, + } as any) expect(modifier).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { type: 'infinite', input: { cursor: 9 } }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + type: 'infinite', + input: { cursor: 9 }, + }) }) }) @@ -412,7 +555,10 @@ describe('procedureUtils', () => { expect(key('__input__')).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { type: 'mutation', input: '__input__' }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + type: 'mutation', + input: '__input__', + }) expect(utils.mutationKey({ key: ['__custom__'] } as any)).toEqual(['__custom__']) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) @@ -446,14 +592,32 @@ describe('procedureUtils', () => { it('includes key options in generated keys', () => { keyedUtils.queryKey({ input: 1 } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { prefix: '__prefix__', serializer, type: 'query', input: 1 }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { + prefix: '__prefix__', + serializer, + type: 'query', + input: 1, + }) - keyedUtils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 0 } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { prefix: '__prefix__', serializer, type: 'infinite', input: { cursor: 0 } }) + keyedUtils.infiniteKey({ + input: (cursor: number) => ({ cursor }), + initialPageParam: 0, + } as any) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { + prefix: '__prefix__', + serializer, + type: 'infinite', + input: { cursor: 0 }, + }) const mutationKey = keyedUtils.mutationKey() as any mutationKey('__input__') - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(3, ['ping'], { prefix: '__prefix__', serializer, type: 'mutation', input: '__input__' }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(3, ['ping'], { + prefix: '__prefix__', + serializer, + type: 'mutation', + input: '__input__', + }) }) }) @@ -468,12 +632,15 @@ describe('procedureUtils', () => { client.mockResolvedValueOnce('__mocked__') await expect(options.query({ signal })).resolves.toEqual('__mocked__') expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith(1, { signal, context: { - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'query', + expect(client).toHaveBeenCalledWith(1, { + signal, + context: { + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'query', + }, }, - } }) + }) }) it('works without options', async () => { @@ -481,17 +648,23 @@ describe('procedureUtils', () => { expect(options.key).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { type: 'query', input: undefined }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + type: 'query', + input: undefined, + }) client.mockResolvedValueOnce('__mocked__') await expect(options.query({ signal })).resolves.toEqual('__mocked__') expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith(undefined, { signal, context: { - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'query', + expect(client).toHaveBeenCalledWith(undefined, { + signal, + context: { + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'query', + }, }, - } }) + }) }) it('works with client context', async () => { @@ -500,13 +673,16 @@ describe('procedureUtils', () => { client.mockResolvedValueOnce('__mocked__') await expect(options.query({ signal })).resolves.toEqual('__mocked__') expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith(undefined, { signal, context: { - batch: true, - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'query', + expect(client).toHaveBeenCalledWith(undefined, { + signal, + context: { + batch: true, + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'query', + }, }, - } }) + }) }) it('passes rest options through', () => { @@ -523,17 +699,22 @@ describe('procedureUtils', () => { client.mockResolvedValueOnce('__mocked__') await expect(options.query({ signal })).resolves.toEqual('__mocked__') - expect(client).toHaveBeenCalledWith(undefined, { signal, context: { - [OPERATION_CONTEXT_SYMBOL]: { - key: ['__custom__'], - type: 'query', + expect(client).toHaveBeenCalledWith(undefined, { + signal, + context: { + [OPERATION_CONTEXT_SYMBOL]: { + key: ['__custom__'], + type: 'query', + }, }, - } }) + }) }) it('uses custom query instead of client but still runs interceptors', async () => { const interceptor = vi.fn(({ next }) => next()) - const interceptedUtils = new ProcedureUtils(['ping'], client, { queryInterceptors: [interceptor] }) + const interceptedUtils = new ProcedureUtils(['ping'], client, { + queryInterceptors: [interceptor], + }) const query = vi.fn().mockResolvedValue('__custom__') const fnContext = { signal } as any @@ -548,47 +729,59 @@ describe('procedureUtils', () => { it('runs interceptors in order & allows overriding options', async () => { const interceptor1 = vi.fn(({ next }) => next()) - const interceptor2 = vi.fn(options => options.next({ - ...options, - input: '__override__', - context: { ...options.context, extra: true }, - })) + const interceptor2 = vi.fn((options) => + options.next({ + ...options, + input: '__override__', + context: { ...options.context, extra: true }, + }), + ) const interceptedUtils = new ProcedureUtils(['ping'], client, { queryInterceptors: [interceptor1, interceptor2], }) - const options = interceptedUtils.queryOptions({ input: 1, context: { batch: true } } as any) as any + const options = interceptedUtils.queryOptions({ + input: 1, + context: { batch: true }, + } as any) as any const fnContext = { signal } as any client.mockResolvedValueOnce('__mocked__') await expect(options.query(fnContext)).resolves.toEqual('__mocked__') expect(interceptor1).toHaveBeenCalledTimes(1) - expect(interceptor1).toHaveBeenCalledWith(expect.objectContaining({ - path: ['ping'], - input: 1, - fnContext, + expect(interceptor1).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['ping'], + input: 1, + fnContext, + context: { + batch: true, + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'query', + }, + }, + }), + ) + expect(interceptor2).toHaveBeenCalledTimes(1) + expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan( + interceptor2.mock.invocationCallOrder[0]!, + ) + + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith('__override__', { + signal, context: { batch: true, + extra: true, [OPERATION_CONTEXT_SYMBOL]: { key: options.key, type: 'query', }, }, - })) - expect(interceptor2).toHaveBeenCalledTimes(1) - expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan(interceptor2.mock.invocationCallOrder[0]!) - - expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith('__override__', { signal, context: { - batch: true, - extra: true, - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'query', - }, - } }) + }) }) it('applies object modifier with per-call options taking precedence', () => { @@ -628,46 +821,74 @@ describe('procedureUtils', () => { expect(options.key).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { type: 'infinite', input: { cursor: 0 } }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + type: 'infinite', + input: { cursor: 0 }, + }) expect(options.initialPageParam).toEqual(0) expect(options.getNextPageParam).toBe(baseOptions.getNextPageParam) client.mockResolvedValueOnce('__mocked__') await expect(options.query({ signal, pageParam: 2 })).resolves.toEqual('__mocked__') expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith({ cursor: 2 }, { signal, context: { - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'infinite', + expect(client).toHaveBeenCalledWith( + { cursor: 2 }, + { + signal, + context: { + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'infinite', + }, + }, }, - } }) + ) }) it('works with initialPageParam as function', () => { - const options = utils.infiniteOptions({ ...baseOptions, initialPageParam: () => 5 } as any) as any + const options = utils.infiniteOptions({ + ...baseOptions, + initialPageParam: () => 5, + } as any) as any expect(options.key).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { type: 'infinite', input: { cursor: 5 } }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + type: 'infinite', + input: { cursor: 5 }, + }) }) it('works with client context', async () => { - const options = utils.infiniteOptions({ ...baseOptions, context: { batch: true } } as any) as any + const options = utils.infiniteOptions({ + ...baseOptions, + context: { batch: true }, + } as any) as any client.mockResolvedValueOnce('__mocked__') await expect(options.query({ signal, pageParam: 1 })).resolves.toEqual('__mocked__') expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith({ cursor: 1 }, { signal, context: { - batch: true, - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'infinite', + expect(client).toHaveBeenCalledWith( + { cursor: 1 }, + { + signal, + context: { + batch: true, + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'infinite', + }, + }, }, - } }) + ) }) it('passes rest options through', () => { - const options = utils.infiniteOptions({ ...baseOptions, maxPages: 3, staleTime: 1000 } as any) as any + const options = utils.infiniteOptions({ + ...baseOptions, + maxPages: 3, + staleTime: 1000, + } as any) as any expect(options.maxPages).toEqual(3) expect(options.staleTime).toEqual(1000) @@ -681,17 +902,25 @@ describe('procedureUtils', () => { client.mockResolvedValueOnce('__mocked__') await expect(options.query({ signal, pageParam: 0 })).resolves.toEqual('__mocked__') - expect(client).toHaveBeenCalledWith({ cursor: 0 }, { signal, context: { - [OPERATION_CONTEXT_SYMBOL]: { - key: ['__custom__'], - type: 'infinite', + expect(client).toHaveBeenCalledWith( + { cursor: 0 }, + { + signal, + context: { + [OPERATION_CONTEXT_SYMBOL]: { + key: ['__custom__'], + type: 'infinite', + }, + }, }, - } }) + ) }) it('uses custom query instead of client but still runs interceptors', async () => { const interceptor = vi.fn(({ next }) => next()) - const interceptedUtils = new ProcedureUtils(['ping'], client, { infiniteInterceptors: [interceptor] }) + const interceptedUtils = new ProcedureUtils(['ping'], client, { + infiniteInterceptors: [interceptor], + }) const query = vi.fn().mockResolvedValue('__custom__') const fnContext = { signal, pageParam: 0 } as any @@ -706,26 +935,49 @@ describe('procedureUtils', () => { it('runs interceptors in order & allows overriding options', async () => { const interceptor1 = vi.fn(({ next }) => next()) - const interceptor2 = vi.fn(options => options.next({ - ...options, - input: '__override__', - })) + const interceptor2 = vi.fn((options) => + options.next({ + ...options, + input: '__override__', + }), + ) const interceptedUtils = new ProcedureUtils(['ping'], client, { infiniteInterceptors: [interceptor1, interceptor2], }) - const options = interceptedUtils.infiniteOptions({ ...baseOptions, context: { batch: true } } as any) as any + const options = interceptedUtils.infiniteOptions({ + ...baseOptions, + context: { batch: true }, + } as any) as any const fnContext = { signal, pageParam: 1 } as any client.mockResolvedValueOnce('__mocked__') await expect(options.query(fnContext)).resolves.toEqual('__mocked__') expect(interceptor1).toHaveBeenCalledTimes(1) - expect(interceptor1).toHaveBeenCalledWith(expect.objectContaining({ - path: ['ping'], - input: { cursor: 1 }, - fnContext, + expect(interceptor1).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['ping'], + input: { cursor: 1 }, + fnContext, + context: { + batch: true, + [OPERATION_CONTEXT_SYMBOL]: { + key: options.key, + type: 'infinite', + }, + }, + }), + ) + expect(interceptor2).toHaveBeenCalledTimes(1) + expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan( + interceptor2.mock.invocationCallOrder[0]!, + ) + + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith('__override__', { + signal, context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { @@ -733,18 +985,7 @@ describe('procedureUtils', () => { type: 'infinite', }, }, - })) - expect(interceptor2).toHaveBeenCalledTimes(1) - expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan(interceptor2.mock.invocationCallOrder[0]!) - - expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith('__override__', { signal, context: { - batch: true, - [OPERATION_CONTEXT_SYMBOL]: { - key: options.key, - type: 'infinite', - }, - } }) + }) }) it('applies object modifier with per-call options taking precedence', () => { @@ -752,7 +993,10 @@ describe('procedureUtils', () => { infiniteOptions: { staleTime: 1000, gcTime: 500 } as any, }) - const options = modifiedUtils.infiniteOptions({ ...baseOptions, staleTime: 2000 } as any) as any + const options = modifiedUtils.infiniteOptions({ + ...baseOptions, + staleTime: 2000, + } as any) as any expect(options.staleTime).toEqual(2000) expect(options.gcTime).toEqual(500) @@ -778,18 +1022,26 @@ describe('procedureUtils', () => { expect(options.key('__input__')).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { type: 'mutation', input: '__input__' }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + type: 'mutation', + input: '__input__', + }) client.mockResolvedValueOnce('__mocked__') await expect(options.mutation(1, {})).resolves.toEqual('__mocked__') expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith(1, { context: { - [OPERATION_CONTEXT_SYMBOL]: { - key: generateOperationKeySpy.mock.results[1]!.value, - type: 'mutation', + expect(client).toHaveBeenCalledWith(1, { + context: { + [OPERATION_CONTEXT_SYMBOL]: { + key: generateOperationKeySpy.mock.results[1]!.value, + type: 'mutation', + }, }, - } }) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { type: 'mutation', input: 1 }) + }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { + type: 'mutation', + input: 1, + }) }) it('works with client context', async () => { @@ -797,18 +1049,23 @@ describe('procedureUtils', () => { expect(options.key('__input__')).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { type: 'mutation', input: '__input__' }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['ping'], { + type: 'mutation', + input: '__input__', + }) client.mockResolvedValueOnce('__mocked__') await expect(options.mutation(1, {})).resolves.toEqual('__mocked__') expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith(1, { context: { - batch: true, - [OPERATION_CONTEXT_SYMBOL]: { - key: generateOperationKeySpy.mock.results[1]!.value, - type: 'mutation', + expect(client).toHaveBeenCalledWith(1, { + context: { + batch: true, + [OPERATION_CONTEXT_SYMBOL]: { + key: generateOperationKeySpy.mock.results[1]!.value, + type: 'mutation', + }, }, - } }) + }) }) it('passes rest options through', () => { @@ -826,17 +1083,21 @@ describe('procedureUtils', () => { client.mockResolvedValueOnce('__mocked__') await expect(options.mutation(1, {})).resolves.toEqual('__mocked__') - expect(client).toHaveBeenCalledWith(1, { context: { - [OPERATION_CONTEXT_SYMBOL]: { - key: ['__custom__'], - type: 'mutation', + expect(client).toHaveBeenCalledWith(1, { + context: { + [OPERATION_CONTEXT_SYMBOL]: { + key: ['__custom__'], + type: 'mutation', + }, }, - } }) + }) }) it('uses custom mutation instead of client but still runs interceptors', async () => { const interceptor = vi.fn(({ next }) => next()) - const interceptedUtils = new ProcedureUtils(['ping'], client, { mutationInterceptors: [interceptor] }) + const interceptedUtils = new ProcedureUtils(['ping'], client, { + mutationInterceptors: [interceptor], + }) const mutation = vi.fn().mockResolvedValue('__custom__') const fnContext = {} as any @@ -851,10 +1112,12 @@ describe('procedureUtils', () => { it('runs interceptors in order & allows overriding options', async () => { const interceptor1 = vi.fn(({ next }) => next()) - const interceptor2 = vi.fn(options => options.next({ - ...options, - input: '__override__', - })) + const interceptor2 = vi.fn((options) => + options.next({ + ...options, + input: '__override__', + }), + ) const interceptedUtils = new ProcedureUtils(['ping'], client, { mutationInterceptors: [interceptor1, interceptor2], @@ -867,10 +1130,27 @@ describe('procedureUtils', () => { await expect(options.mutation(1, fnContext)).resolves.toEqual('__mocked__') expect(interceptor1).toHaveBeenCalledTimes(1) - expect(interceptor1).toHaveBeenCalledWith(expect.objectContaining({ - path: ['ping'], - input: 1, - fnContext, + expect(interceptor1).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['ping'], + input: 1, + fnContext, + context: { + batch: true, + [OPERATION_CONTEXT_SYMBOL]: { + key: generateOperationKeySpy.mock.results[0]!.value, + type: 'mutation', + }, + }, + }), + ) + expect(interceptor2).toHaveBeenCalledTimes(1) + expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan( + interceptor2.mock.invocationCallOrder[0]!, + ) + + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith('__override__', { context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { @@ -878,18 +1158,7 @@ describe('procedureUtils', () => { type: 'mutation', }, }, - })) - expect(interceptor2).toHaveBeenCalledTimes(1) - expect(interceptor1.mock.invocationCallOrder[0]!).toBeLessThan(interceptor2.mock.invocationCallOrder[0]!) - - expect(client).toHaveBeenCalledTimes(1) - expect(client).toHaveBeenCalledWith('__override__', { context: { - batch: true, - [OPERATION_CONTEXT_SYMBOL]: { - key: generateOperationKeySpy.mock.results[0]!.value, - type: 'mutation', - }, - } }) + }) }) it('applies object modifier with per-call options taking precedence', () => { @@ -924,8 +1193,12 @@ describe('isProcedureUtilsOptions', () => { expect(isProcedureUtilsOptions({})).toBe(true) expect(isProcedureUtilsOptions({ prefix: 'planet' })).toBe(true) expect(isProcedureUtilsOptions({ serializer: new RPCJsonSerializer() })).toBe(true) - expect(isProcedureUtilsOptions({ queryInterceptors: [], mutationInterceptors: [vi.fn()] })).toBe(true) - expect(isProcedureUtilsOptions({ queryOptions: { staleTime: 1000 }, mutationKey: () => ({}) })).toBe(true) + expect( + isProcedureUtilsOptions({ queryInterceptors: [], mutationInterceptors: [vi.fn()] }), + ).toBe(true) + expect( + isProcedureUtilsOptions({ queryOptions: { staleTime: 1000 }, mutationKey: () => ({}) }), + ).toBe(true) expect(isProcedureUtilsOptions({ liveOptions: undefined, streamedKey: undefined })).toBe(true) expect(isProcedureUtilsOptions({ unknown: 'allowed' })).toBe(true) }) @@ -991,7 +1264,11 @@ describe('mergeProcedureUtilsOptions', () => { { queryOptions: (options: any) => ({ ...options, gcTime: 2 }) }, ) - expect((merged.queryOptions as any)({ enabled: true })).toEqual({ enabled: true, staleTime: 1000, gcTime: 2 }) + expect((merged.queryOptions as any)({ enabled: true })).toEqual({ + enabled: true, + staleTime: 1000, + gcTime: 2, + }) }) it('composes mixed object and function modifiers', () => { @@ -1000,14 +1277,22 @@ describe('mergeProcedureUtilsOptions', () => { { queryOptions: (options: any) => ({ ...options, gcTime: 2 }) }, ) - expect((objectFirst.queryOptions as any)({ enabled: true })).toEqual({ enabled: true, staleTime: 1000, gcTime: 2 }) + expect((objectFirst.queryOptions as any)({ enabled: true })).toEqual({ + enabled: true, + staleTime: 1000, + gcTime: 2, + }) const functionFirst = mergeProcedureUtilsOptions( { queryOptions: (options: any) => ({ ...options, staleTime: 1000 }) }, { queryOptions: { gcTime: 2 } }, ) - expect((functionFirst.queryOptions as any)({ enabled: true })).toEqual({ enabled: true, staleTime: 1000, gcTime: 2 }) + expect((functionFirst.queryOptions as any)({ enabled: true })).toEqual({ + enabled: true, + staleTime: 1000, + gcTime: 2, + }) }) it('lets an object override beat defaults of a function base, below per-call options', () => { diff --git a/packages/pinia-colada/src/procedure-utils.ts b/packages/pinia-colada/src/procedure-utils.ts index d78b0d41ec..b149e3e664 100644 --- a/packages/pinia-colada/src/procedure-utils.ts +++ b/packages/pinia-colada/src/procedure-utils.ts @@ -1,50 +1,127 @@ import type { Client, ClientContext } from '@orpc/client' import type { Interceptor, MaybeOptionalOptions, PromiseWithError } from '@orpc/shared' -import type { _EmptyObject, EntryKeyTagged, UseInfiniteQueryData, UseInfiniteQueryFnContext } from '@pinia/colada' -import type { OperationKeyBaseOptions } from './key' -import type { InferLiveQueryOutput, InferStreamedQueryOutput, InfiniteKeyOptions, InfiniteOptionsIn, InfiniteOptionsOut, MutationKeyOptions, MutationOptionsIn, MutationOptionsOut, OperationContext, QueryKeyOptions, QueryOptionsIn, QueryOptionsOut, StreamedKeyOptions, StreamedOptionsIn, StreamedOptionsOut, UseMutationFnContext, UseQueryFnContext } from './types' -import { intercept, isAsyncIteratorObject, isTypescriptObject, resolveMaybeOptionalOptions } from '@orpc/shared' -import { liveQuery } from './live-query' -import { SharedUtils } from './shared-utils' -import { serializableStreamedQuery } from './stream-query' -import { OPERATION_CONTEXT_SYMBOL } from './types' - -export interface ProcedureUtilsQueryInterceptorOptions { +import { + intercept, + isAsyncIteratorObject, + isTypescriptObject, + resolveMaybeOptionalOptions, +} from '@orpc/shared' +import type { + _EmptyObject, + EntryKeyTagged, + UseInfiniteQueryData, + UseInfiniteQueryFnContext, +} from '@pinia/colada' + +import type { OperationKeyBaseOptions } from './key.ts' +import { liveQuery } from './live-query.ts' +import { SharedUtils } from './shared-utils.ts' +import { serializableStreamedQuery } from './stream-query.ts' +import type { + InferLiveQueryOutput, + InferStreamedQueryOutput, + InfiniteKeyOptions, + InfiniteOptionsIn, + InfiniteOptionsOut, + MutationKeyOptions, + MutationOptionsIn, + MutationOptionsOut, + OperationContext, + QueryKeyOptions, + QueryOptionsIn, + QueryOptionsOut, + StreamedKeyOptions, + StreamedOptionsIn, + StreamedOptionsOut, + UseMutationFnContext, + UseQueryFnContext, +} from './types.ts' +import { OPERATION_CONTEXT_SYMBOL } from './types.ts' + +export interface ProcedureUtilsQueryInterceptorOptions< + TClientContext extends ClientContext, + TInput, +> { path: string[] context: TClientContext & OperationContext input: TInput fnContext: UseQueryFnContext } -export type ProcedureUtilsQueryInterceptor - = Interceptor, PromiseWithError> - -export interface ProcedureUtilsStreamedInterceptorOptions extends ProcedureUtilsQueryInterceptorOptions { -} -export type ProcedureUtilsStreamedInterceptor - = Interceptor, PromiseWithError, TError>> - -export interface ProcedureUtilsLiveInterceptorOptions extends ProcedureUtilsQueryInterceptorOptions { -} -export type ProcedureUtilsLiveInterceptor - = Interceptor, PromiseWithError, TError>> - -export interface ProcedureUtilsInfiniteInterceptorOptions { +export type ProcedureUtilsQueryInterceptor< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> = Interceptor< + ProcedureUtilsQueryInterceptorOptions, + PromiseWithError +> + +export interface ProcedureUtilsStreamedInterceptorOptions< + TClientContext extends ClientContext, + TInput, +> extends ProcedureUtilsQueryInterceptorOptions {} +export type ProcedureUtilsStreamedInterceptor< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> = Interceptor< + ProcedureUtilsStreamedInterceptorOptions, + PromiseWithError, TError> +> + +export interface ProcedureUtilsLiveInterceptorOptions< + TClientContext extends ClientContext, + TInput, +> extends ProcedureUtilsQueryInterceptorOptions {} +export type ProcedureUtilsLiveInterceptor< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> = Interceptor< + ProcedureUtilsLiveInterceptorOptions, + PromiseWithError, TError> +> + +export interface ProcedureUtilsInfiniteInterceptorOptions< + TClientContext extends ClientContext, + TInput, +> { path: string[] context: TClientContext & OperationContext input: TInput fnContext: UseInfiniteQueryFnContext } -export type ProcedureUtilsInfiniteInterceptor - = Interceptor, PromiseWithError> - -export interface ProcedureUtilsMutationInterceptorOptions { +export type ProcedureUtilsInfiniteInterceptor< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> = Interceptor< + ProcedureUtilsInfiniteInterceptorOptions, + PromiseWithError +> + +export interface ProcedureUtilsMutationInterceptorOptions< + TClientContext extends ClientContext, + TInput, +> { path: string[] context: TClientContext & OperationContext input: TInput fnContext: UseMutationFnContext } -export type ProcedureUtilsMutationInterceptor - = Interceptor, PromiseWithError> +export type ProcedureUtilsMutationInterceptor< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> = Interceptor< + ProcedureUtilsMutationInterceptorOptions, + PromiseWithError +> /** * Can be partial options for spread-merged options, @@ -52,111 +129,145 @@ export type ProcedureUtilsMutationInterceptor = Partial | ((options: T) => T) -export interface ProcedureUtilsOptions extends OperationKeyBaseOptions { +export interface ProcedureUtilsOptions< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> extends OperationKeyBaseOptions { /** * Key options modifier for .queryKey and .queryOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - queryKey?: ProcedureUtilsModifier< - QueryKeyOptions - > | undefined + queryKey?: ProcedureUtilsModifier> | undefined /** * Interceptors that intercept query inside .queryOptions, guaranteed to be executed. */ - queryInterceptors?: ProcedureUtilsQueryInterceptor[] | undefined + queryInterceptors?: + | ProcedureUtilsQueryInterceptor[] + | undefined /** * Options modifier for .queryOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - queryOptions?: ProcedureUtilsModifier< - QueryOptionsIn - > | undefined + queryOptions?: + | ProcedureUtilsModifier< + QueryOptionsIn + > + | undefined /** * Key options modifier for .streamedKey and .streamedOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - streamedKey?: ProcedureUtilsModifier< - StreamedKeyOptions - > | undefined + streamedKey?: ProcedureUtilsModifier> | undefined /** * Interceptors that intercept query inside .streamedOptions, guaranteed to be executed. */ - streamedInterceptors?: ProcedureUtilsStreamedInterceptor[] | undefined + streamedInterceptors?: + | ProcedureUtilsStreamedInterceptor[] + | undefined /** * Options modifier for .streamedOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - streamedOptions?: ProcedureUtilsModifier< - StreamedOptionsIn, TError, InferStreamedQueryOutput | undefined> - > | undefined + streamedOptions?: + | ProcedureUtilsModifier< + StreamedOptionsIn< + TClientContext, + TInput, + InferStreamedQueryOutput, + TError, + InferStreamedQueryOutput | undefined + > + > + | undefined /** * Key options modifier for .liveKey and .liveOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - liveKey?: ProcedureUtilsModifier< - QueryKeyOptions - > | undefined + liveKey?: ProcedureUtilsModifier> | undefined /** * Interceptors that intercept query inside .liveOptions, guaranteed to be executed. */ - liveInterceptors?: ProcedureUtilsLiveInterceptor[] | undefined + liveInterceptors?: + | ProcedureUtilsLiveInterceptor[] + | undefined /** * Options modifier for .liveOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - liveOptions?: ProcedureUtilsModifier< - QueryOptionsIn, TError, InferLiveQueryOutput | undefined> - > | undefined + liveOptions?: + | ProcedureUtilsModifier< + QueryOptionsIn< + TClientContext, + TInput, + InferLiveQueryOutput, + TError, + InferLiveQueryOutput | undefined + > + > + | undefined /** * Key options modifier for .infiniteKey and .infiniteOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - infiniteKey?: ProcedureUtilsModifier< - InfiniteKeyOptions - > | undefined + infiniteKey?: ProcedureUtilsModifier> | undefined /** * Interceptors that intercept query inside .infiniteOptions, guaranteed to be executed. */ - infiniteInterceptors?: ProcedureUtilsInfiniteInterceptor[] | undefined + infiniteInterceptors?: + | ProcedureUtilsInfiniteInterceptor[] + | undefined /** * Options modifier for .infiniteOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - infiniteOptions?: ProcedureUtilsModifier< - InfiniteOptionsIn | undefined> - > | undefined + infiniteOptions?: + | ProcedureUtilsModifier< + InfiniteOptionsIn< + TClientContext, + TInput, + TOutput, + TError, + unknown, + UseInfiniteQueryData | undefined + > + > + | undefined /** * Key options modifier for .mutationKey and .mutationOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - mutationKey?: ProcedureUtilsModifier< - MutationKeyOptions - > | undefined + mutationKey?: ProcedureUtilsModifier> | undefined /** * Interceptors that intercept mutation inside .mutationOptions, guaranteed to be executed. */ - mutationInterceptors?: ProcedureUtilsMutationInterceptor[] | undefined + mutationInterceptors?: + | ProcedureUtilsMutationInterceptor[] + | undefined /** * Options modifier for .mutationOptions * Can be partial options or a function that receives per-call options and returns override this.options. */ - mutationOptions?: ProcedureUtilsModifier< - MutationOptionsIn> - > | undefined + mutationOptions?: + | ProcedureUtilsModifier< + MutationOptionsIn> + > + | undefined } const PROCEDURE_UTILS_INTERCEPTOR_KEYS: string[] = [ @@ -180,7 +291,9 @@ const PROCEDURE_UTILS_MODIFIER_KEYS: string[] = [ 'mutationOptions', ] -export function isProcedureUtilsOptions(value: unknown): value is ProcedureUtilsOptions { +export function isProcedureUtilsOptions( + value: unknown, +): value is ProcedureUtilsOptions { if (!isTypescriptObject(value)) { return false } @@ -194,18 +307,15 @@ export function isProcedureUtilsOptions(value: unknown): value is ProcedureUtils if (typeof value[key] !== 'string') { return false } - } - else if (key === 'serializer') { + } else if (key === 'serializer') { if (!isTypescriptObject(value[key]) || typeof value[key].serializeJson !== 'function') { return false } - } - else if (PROCEDURE_UTILS_INTERCEPTOR_KEYS.includes(key)) { - if (!Array.isArray(value[key]) || value[key].some(i => typeof i !== 'function')) { + } else if (PROCEDURE_UTILS_INTERCEPTOR_KEYS.includes(key)) { + if (!Array.isArray(value[key]) || value[key].some((i) => typeof i !== 'function')) { return false } - } - else if (PROCEDURE_UTILS_MODIFIER_KEYS.includes(key)) { + } else if (PROCEDURE_UTILS_MODIFIER_KEYS.includes(key)) { if (!isTypescriptObject(value[key])) { return false } @@ -224,12 +334,12 @@ function mergeProcedureUtilsModifier( return { ...base, ...override } as Partial } - return options => base({ ...override, ...options }) + return (options) => base({ ...override, ...options }) } const applyBase = typeof base === 'function' ? base : (options: T) => ({ ...base, ...options }) - return options => override(applyBase(options)) + return (options) => override(applyBase(options)) } /** @@ -239,15 +349,24 @@ function mergeProcedureUtilsModifier( * modifiers are combined so that, for object values, per-call options beat `override`, * which beats `base`. A function modifier receives the merged options and its result wins. */ -export function mergeProcedureUtilsOptions( +export function mergeProcedureUtilsOptions< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +>( base: ProcedureUtilsOptions, override: ProcedureUtilsOptions, ): ProcedureUtilsOptions { const merged: Record = { ...base, ...override } for (const key of PROCEDURE_UTILS_INTERCEPTOR_KEYS) { - const baseValue = (base as Record)[key] as ProcedureUtilsQueryInterceptor[] | undefined - const overrideValue = (override as Record)[key] as ProcedureUtilsQueryInterceptor[] | undefined + const baseValue = (base as Record)[key] as + | ProcedureUtilsQueryInterceptor[] + | undefined + const overrideValue = (override as Record)[key] as + | ProcedureUtilsQueryInterceptor[] + | undefined if (baseValue && overrideValue) { merged[key] = [...baseValue, ...overrideValue] @@ -255,8 +374,12 @@ export function mergeProcedureUtilsOptions)[key] as ProcedureUtilsModifier | undefined - const overrideValue = (override as Record)[key] as ProcedureUtilsModifier | undefined + const baseValue = (base as Record)[key] as + | ProcedureUtilsModifier + | undefined + const overrideValue = (override as Record)[key] as + | ProcedureUtilsModifier + | undefined if (baseValue && overrideValue) { merged[key] = mergeProcedureUtilsModifier(baseValue, overrideValue) @@ -266,7 +389,12 @@ export function mergeProcedureUtilsOptions } -export class ProcedureUtils extends SharedUtils { +export class ProcedureUtils< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, +> extends SharedUtils { declare protected readonly options: ProcedureUtilsOptions /** @@ -297,13 +425,12 @@ export class ProcedureUtils } @@ -322,12 +449,17 @@ export class ProcedureUtils + const { + input, + context, + key: _keyIn, + query: queryIn, + ...restOptions + } = optionsIn as Record const key = this.queryKey(optionsIn as QueryKeyOptions) @@ -373,13 +505,17 @@ export class ProcedureUtils, TError> } @@ -391,19 +527,31 @@ export class ProcedureUtils | undefined = undefined>( ...rest: MaybeOptionalOptions< - StreamedOptionsIn, TError, UInitialData> + StreamedOptionsIn< + TClientContext, + TInput, + InferStreamedQueryOutput, + TError, + UInitialData + > > ): NoInfer, TError, UInitialData>> { let optionsIn = resolveMaybeOptionalOptions(rest) if (typeof this.options.streamedOptions === 'function') { optionsIn = this.options.streamedOptions(optionsIn as any) as any - } - else if (this.options.streamedOptions) { + } else if (this.options.streamedOptions) { optionsIn = { ...this.options.streamedOptions, ...optionsIn } as any } - const { input, context, key: _keyIn, query: queryIn, fnOptions, ...restOptions } = optionsIn as Record + const { + input, + context, + key: _keyIn, + query: queryIn, + fnOptions, + ...restOptions + } = optionsIn as Record const key = this.streamedKey(optionsIn as StreamedKeyOptions) @@ -427,21 +575,21 @@ export class ProcedureUtils { if (queryIn) { - return queryIn(fnContext) as PromiseWithError, TError> + return queryIn(fnContext) as PromiseWithError< + InferStreamedQueryOutput, + TError + > } - return serializableStreamedQuery( - async (queryContext) => { - const output = await this.call(input, { signal: queryContext.signal, context }) + return serializableStreamedQuery(async (queryContext) => { + const output = await this.call(input, { signal: queryContext.signal, context }) - if (!isAsyncIteratorObject(output)) { - throw new Error('streamedQuery requires an AsyncIteratorObject output') - } + if (!isAsyncIteratorObject(output)) { + throw new Error('streamedQuery requires an AsyncIteratorObject output') + } - return output - }, - fnOptions, - )(fnContext) as PromiseWithError, TError> + return output + }, fnOptions)(fnContext) as PromiseWithError, TError> }, ) }, @@ -460,13 +608,12 @@ export class ProcedureUtils, TError> } @@ -485,12 +632,17 @@ export class ProcedureUtils + const { + input, + context, + key: _keyIn, + query: queryIn, + ...restOptions + } = optionsIn as Record const key = this.liveKey(optionsIn as QueryKeyOptions) @@ -542,13 +694,13 @@ export class ProcedureUtils, TError> { if (typeof this.options.infiniteKey === 'function') { optionsIn = this.options.infiniteKey(optionsIn as any) as any - } - else if (this.options.infiniteKey) { + } else if (this.options.infiniteKey) { optionsIn = { ...this.options.infiniteKey, ...optionsIn } as any } - const key = (optionsIn as any).key - ?? this.key({ + const key = + (optionsIn as any).key ?? + this.key({ type: 'infinite', input: (optionsIn as any).input( typeof (optionsIn as any).initialPageParam === 'function' @@ -565,17 +717,25 @@ export class ProcedureUtils | undefined = undefined>( + infiniteOptions< + UPageParam, + UInitialData extends UseInfiniteQueryData | undefined = undefined, + >( optionsIn: InfiniteOptionsIn, ): NoInfer> { if (typeof this.options.infiniteOptions === 'function') { optionsIn = this.options.infiniteOptions(optionsIn as any) as any - } - else if (this.options.infiniteOptions) { + } else if (this.options.infiniteOptions) { optionsIn = { ...this.options.infiniteOptions, ...optionsIn } as any } - const { input, context, key: _keyIn, query: queryIn, ...restOptions } = optionsIn as Record + const { + input, + context, + key: _keyIn, + query: queryIn, + ...restOptions + } = optionsIn as Record const key = this.infiniteKey(optionsIn as InfiniteKeyOptions) @@ -621,13 +781,12 @@ export class ProcedureUtils this.key({ type: 'mutation', input: input as any })) + const key = + optionsIn.key ?? ((input: TInput) => this.key({ type: 'mutation', input: input as any })) return key as MutationOptionsOut['key'] } @@ -646,12 +805,16 @@ export class ProcedureUtils + const { + context, + key: _keyIn, + mutation: mutationIn, + ...restOptions + } = optionsIn as Record const key = this.mutationKey(optionsIn as MutationKeyOptions) diff --git a/packages/pinia-colada/src/router-utils.test-d.ts b/packages/pinia-colada/src/router-utils.test-d.ts index 8fa6bbd711..af5182cfd1 100644 --- a/packages/pinia-colada/src/router-utils.test-d.ts +++ b/packages/pinia-colada/src/router-utils.test-d.ts @@ -1,17 +1,18 @@ -import type { ORPCErrorFromErrorMap } from '@orpc/contract' -import type { RouterClient } from '@orpc/server' -import type { Public } from '@orpc/shared' -import type { ProcedureUtils } from './procedure-utils' -import type { RouterUtils } from './router-utils' -import type { SharedUtils } from './shared-utils' import { RPCSerializer } from '@orpc/client' +import type { ORPCErrorFromErrorMap } from '@orpc/contract' import { OpenAPISerializer } from '@orpc/openapi' +import type { RouterClient } from '@orpc/server' import { os } from '@orpc/server' -import z from 'zod' -import { createRouterUtils } from './router-utils' +import type { Public } from '@orpc/shared' +import * as z from 'zod' + +import type { ProcedureUtils } from './procedure-utils.ts' +import type { RouterUtils } from './router-utils.ts' +import { createRouterUtils } from './router-utils.ts' +import type { SharedUtils } from './shared-utils.ts' -const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) -const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) +const inputSchema = z.object({ input: z.number().transform((n) => `${n}`) }) +const outputSchema = z.object({ output: z.number().transform((n) => `${n}`) }) const unknownSchema = z.unknown() const baseErrorMap = { @@ -21,8 +22,15 @@ const baseErrorMap = { OVERRIDE: {}, } -const ping = os.input(inputSchema).output(outputSchema).errors(baseErrorMap).handler(() => ({ output: 1 })) -const pong = os.input(unknownSchema).output(unknownSchema).handler(() => 'pong') +const ping = os + .input(inputSchema) + .output(outputSchema) + .errors(baseErrorMap) + .handler(() => ({ output: 1 })) +const pong = os + .input(unknownSchema) + .output(unknownSchema) + .handler(() => 'pong') const router = { ping, @@ -43,10 +51,24 @@ it('RouterUtils', () => { expectTypeOf(utils.nested.ping).toExtend>>() expectTypeOf(utils.ping).toExtend< - Public | Error>> + Public< + ProcedureUtils< + { batch?: boolean }, + { input: number }, + { output: string }, + ORPCErrorFromErrorMap | Error + > + > >() expectTypeOf(utils.nested.ping).toExtend< - Public | Error>> + Public< + ProcedureUtils< + { batch?: boolean }, + { input: number }, + { output: string }, + ORPCErrorFromErrorMap | Error + > + > >() expectTypeOf(utils.pong).toExtend< @@ -94,7 +116,7 @@ it('createRouterUtils', () => { plugins: [ { name: 'test-plugin', - init: options => options, + init: (options) => options, initProcedureOptions: (path, options) => { expectTypeOf(path).toEqualTypeOf() diff --git a/packages/pinia-colada/src/router-utils.test.ts b/packages/pinia-colada/src/router-utils.test.ts index 94b2487bac..137dfcccdd 100644 --- a/packages/pinia-colada/src/router-utils.test.ts +++ b/packages/pinia-colada/src/router-utils.test.ts @@ -1,24 +1,27 @@ -import type { RouterUtilsPlugin } from './plugin' import { RPCJsonSerializer } from '@orpc/client' -import * as KeyModule from './key' -import { ProcedureUtils } from './procedure-utils' -import { createRouterUtils } from './router-utils' -vi.mock('./procedure-utils', async (importOriginal) => { - const { SharedUtils } = await import('./shared-utils') +import * as KeyModule from './key.ts' +import type { RouterUtilsPlugin } from './plugin.ts' +import { ProcedureUtils } from './procedure-utils.ts' +import { createRouterUtils } from './router-utils.ts' + +vi.mock('./procedure-utils.ts', async (importOriginal) => { + const { SharedUtils } = await import('./shared-utils.ts') return { - ...await importOriginal(), - ProcedureUtils: vi.fn(class extends SharedUtils { - call = vi.fn() - override key = SharedUtils.prototype.key - queryOptions = vi.fn(() => ({ queryOptions: true })) - mutationOptions = vi.fn(() => ({ mutationOptions: true })) - - constructor(path: string[], _client: unknown, options: any = {}) { - super(path, options) - } - }), + ...(await importOriginal()), + ProcedureUtils: vi.fn( + class extends SharedUtils { + call = vi.fn() + override key = SharedUtils.prototype.key + queryOptions = vi.fn(() => ({ queryOptions: true })) + mutationOptions = vi.fn(() => ({ mutationOptions: true })) + + constructor(path: string[], _client: unknown, options: any = {}) { + super(path, options) + } + }, + ), } }) @@ -43,24 +46,48 @@ describe('createRouterUtils', () => { expect(ProcedureUtils).toHaveBeenCalledTimes(1) expect(ProcedureUtils).toHaveBeenCalledWith([], client, { prefix: '__prefix__', serializer }) expect(utils.key({ type: 'query' })).toBe(generateOperationKeySpy.mock.results[0]?.value) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, [], { type: 'query', prefix: '__prefix__', serializer }) - expect(utils.queryOptions()).toBe(vi.mocked(ProcedureUtils).mock.results[0]?.value.queryOptions.mock.results[0]?.value) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, [], { + type: 'query', + prefix: '__prefix__', + serializer, + }) + expect(utils.queryOptions()).toBe( + vi.mocked(ProcedureUtils).mock.results[0]?.value.queryOptions.mock.results[0]?.value, + ) const keyUtils = utils.key expect(ProcedureUtils).toHaveBeenCalledTimes(2) - expect(ProcedureUtils).toHaveBeenNthCalledWith(2, ['key'], client.key, { prefix: '__prefix__', serializer }) + expect(ProcedureUtils).toHaveBeenNthCalledWith(2, ['key'], client.key, { + prefix: '__prefix__', + serializer, + }) expect(keyUtils.key({ type: 'mutation' })).toBe(generateOperationKeySpy.mock.results[1]?.value) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['key'], { type: 'mutation', prefix: '__prefix__', serializer }) - expect(keyUtils.queryOptions()).toBe(vi.mocked(ProcedureUtils).mock.results[1]?.value.queryOptions.mock.results[0]?.value) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['key'], { + type: 'mutation', + prefix: '__prefix__', + serializer, + }) + expect(keyUtils.queryOptions()).toBe( + vi.mocked(ProcedureUtils).mock.results[1]?.value.queryOptions.mock.results[0]?.value, + ) const pongUtils = keyUtils.pong expect(ProcedureUtils).toHaveBeenCalledTimes(3) - expect(ProcedureUtils).toHaveBeenNthCalledWith(3, ['key', 'pong'], client.key.pong, { prefix: '__prefix__', serializer }) + expect(ProcedureUtils).toHaveBeenNthCalledWith(3, ['key', 'pong'], client.key.pong, { + prefix: '__prefix__', + serializer, + }) expect(pongUtils.key({ type: 'query' })).toBe(generateOperationKeySpy.mock.results[2]?.value) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(3, ['key', 'pong'], { type: 'query', prefix: '__prefix__', serializer }) - expect(pongUtils.queryOptions()).toBe(vi.mocked(ProcedureUtils).mock.results[2]?.value.queryOptions.mock.results[0]?.value) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(3, ['key', 'pong'], { + type: 'query', + prefix: '__prefix__', + serializer, + }) + expect(pongUtils.queryOptions()).toBe( + vi.mocked(ProcedureUtils).mock.results[2]?.value.queryOptions.mock.results[0]?.value, + ) }) it('returns strictly equal utils on repeated access', () => { @@ -92,7 +119,9 @@ describe('createRouterUtils', () => { expect(ProcedureUtils).toHaveBeenCalledTimes(1) expect(ProcedureUtils).toHaveBeenCalledWith(['nested', 'ping'], router.nested.ping, {}) - expect(pingUtils.queryOptions()).toBe(vi.mocked(ProcedureUtils).mock.results[0]?.value.queryOptions.mock.results[0]?.value) + expect(pingUtils.queryOptions()).toBe( + vi.mocked(ProcedureUtils).mock.results[0]?.value.queryOptions.mock.results[0]?.value, + ) }) it('stops recursive on symbol', () => { @@ -106,6 +135,7 @@ describe('createRouterUtils', () => { }) it('stops recursive on RECURSIVE_CLIENT_UNWRAP_KEYS', () => { + // oxlint-disable-next-line unicorn/no-thenable client.then = vi.fn() try { @@ -113,8 +143,7 @@ describe('createRouterUtils', () => { expect(utils.then).toBe(undefined) expect(ProcedureUtils).toHaveBeenCalledTimes(1) // only the root utils - } - finally { + } finally { delete client.then } }) @@ -155,8 +184,7 @@ describe('createRouterUtils', () => { vi.clearAllMocks() expect(typeof keyUtils.bind).toBe('function') // Function.prototype.bind, not a nested utils expect(ProcedureUtils).toHaveBeenCalledTimes(0) - } - finally { + } finally { delete client.key.bind } }) @@ -186,7 +214,9 @@ describe('createRouterUtils', () => { const pongUtils = keyUtils.pong expect(ProcedureUtils).toHaveBeenCalledTimes(1) expect(ProcedureUtils).toHaveBeenCalledWith(['key', 'pong'], client.key.pong, {}) - expect(pongUtils.queryOptions()).toBe(vi.mocked(ProcedureUtils).mock.results[0]?.value.queryOptions.mock.results[0]?.value) + expect(pongUtils.queryOptions()).toBe( + vi.mocked(ProcedureUtils).mock.results[0]?.value.queryOptions.mock.results[0]?.value, + ) }) it('merges interceptors and applies plugin hooks before creating procedure utils', () => { @@ -263,42 +293,86 @@ describe('createRouterUtils', () => { } as any) as any expect(plugin.init).toHaveBeenCalledOnce() - expect(plugin.init).toHaveBeenCalledWith(expect.objectContaining({ - queryInterceptors: [queryInterceptors.root], - streamedInterceptors: [streamedInterceptors.root], - liveInterceptors: [liveInterceptors.root], - infiniteInterceptors: [infiniteInterceptors.root], - mutationInterceptors: [mutationInterceptors.root], - })) + expect(plugin.init).toHaveBeenCalledWith( + expect.objectContaining({ + queryInterceptors: [queryInterceptors.root], + streamedInterceptors: [streamedInterceptors.root], + liveInterceptors: [liveInterceptors.root], + infiniteInterceptors: [infiniteInterceptors.root], + mutationInterceptors: [mutationInterceptors.root], + }), + ) const routeUtils = utils.route expect(plugin.initProcedureOptions).toHaveBeenCalledOnce() - expect(plugin.initProcedureOptions).toHaveBeenCalledWith(['route'], expect.objectContaining({ - queryInterceptors: [queryInterceptors.root, queryInterceptors.init, queryInterceptors.scoped], - streamedInterceptors: [streamedInterceptors.root, streamedInterceptors.init, streamedInterceptors.scoped], - liveInterceptors: [liveInterceptors.root, liveInterceptors.init, liveInterceptors.scoped], - infiniteInterceptors: [infiniteInterceptors.root, infiniteInterceptors.init, infiniteInterceptors.scoped], - mutationInterceptors: [mutationInterceptors.root, mutationInterceptors.init, mutationInterceptors.scoped], - queryOptions: { - gcTime: 1000, - }, - })) + expect(plugin.initProcedureOptions).toHaveBeenCalledWith( + ['route'], + expect.objectContaining({ + queryInterceptors: [ + queryInterceptors.root, + queryInterceptors.init, + queryInterceptors.scoped, + ], + streamedInterceptors: [ + streamedInterceptors.root, + streamedInterceptors.init, + streamedInterceptors.scoped, + ], + liveInterceptors: [liveInterceptors.root, liveInterceptors.init, liveInterceptors.scoped], + infiniteInterceptors: [ + infiniteInterceptors.root, + infiniteInterceptors.init, + infiniteInterceptors.scoped, + ], + mutationInterceptors: [ + mutationInterceptors.root, + mutationInterceptors.init, + mutationInterceptors.scoped, + ], + queryOptions: { + gcTime: 1000, + }, + }), + ) expect(ProcedureUtils).toHaveBeenCalledTimes(1) - expect(ProcedureUtils).toHaveBeenCalledWith(['route'], client.route, expect.objectContaining({ - queryInterceptors: [queryInterceptors.root, queryInterceptors.init, queryInterceptors.scoped, queryInterceptors.procedure], - streamedInterceptors: [streamedInterceptors.root, streamedInterceptors.init, streamedInterceptors.scoped], - liveInterceptors: [liveInterceptors.root, liveInterceptors.init, liveInterceptors.scoped], - infiniteInterceptors: [infiniteInterceptors.root, infiniteInterceptors.init, infiniteInterceptors.scoped], - mutationInterceptors: [mutationInterceptors.root, mutationInterceptors.init, mutationInterceptors.scoped], - queryOptions: { - gcTime: 1000, - staleTime: 100, - }, - })) + expect(ProcedureUtils).toHaveBeenCalledWith( + ['route'], + client.route, + expect.objectContaining({ + queryInterceptors: [ + queryInterceptors.root, + queryInterceptors.init, + queryInterceptors.scoped, + queryInterceptors.procedure, + ], + streamedInterceptors: [ + streamedInterceptors.root, + streamedInterceptors.init, + streamedInterceptors.scoped, + ], + liveInterceptors: [liveInterceptors.root, liveInterceptors.init, liveInterceptors.scoped], + infiniteInterceptors: [ + infiniteInterceptors.root, + infiniteInterceptors.init, + infiniteInterceptors.scoped, + ], + mutationInterceptors: [ + mutationInterceptors.root, + mutationInterceptors.init, + mutationInterceptors.scoped, + ], + queryOptions: { + gcTime: 1000, + staleTime: 100, + }, + }), + ) - expect(routeUtils.queryOptions()).toBe(vi.mocked(ProcedureUtils).mock.results[0]?.value.queryOptions.mock.results[0]?.value) + expect(routeUtils.queryOptions()).toBe( + vi.mocked(ProcedureUtils).mock.results[0]?.value.queryOptions.mock.results[0]?.value, + ) }) it('does not create procedure utils when scoped options are not procedure utils options', () => { @@ -323,9 +397,13 @@ describe('createRouterUtils', () => { const childUtils = utils.child expect(ProcedureUtils).toHaveBeenCalledTimes(1) - expect(ProcedureUtils).toHaveBeenCalledWith(['child'], client.child, expect.objectContaining({ - queryOptions: { staleTime: 1000 }, - })) + expect(ProcedureUtils).toHaveBeenCalledWith( + ['child'], + client.child, + expect.objectContaining({ + queryOptions: { staleTime: 1000 }, + }), + ) expect(typeof childUtils.queryOptions).toBe('function') }) }) diff --git a/packages/pinia-colada/src/router-utils.ts b/packages/pinia-colada/src/router-utils.ts index bb77a846a7..6e4c6f8b24 100644 --- a/packages/pinia-colada/src/router-utils.ts +++ b/packages/pinia-colada/src/router-utils.ts @@ -1,13 +1,25 @@ import type { AnyNestedClient, Client, InferClientContext, InferClientError } from '@orpc/client' -import type { Public } from '@orpc/shared' -import type { OperationKeyBaseOptions } from './key' -import type { RouterUtilsPlugin } from './plugin' -import type { ProcedureUtilsInfiniteInterceptor, ProcedureUtilsLiveInterceptor, ProcedureUtilsMutationInterceptor, ProcedureUtilsOptions, ProcedureUtilsQueryInterceptor, ProcedureUtilsStreamedInterceptor } from './procedure-utils' import { RECURSIVE_CLIENT_UNWRAP_KEYS } from '@orpc/client' +import type { Public } from '@orpc/shared' import { bindMethods, isTypescriptObject, pick, toArray } from '@orpc/shared' -import { CompositeRouterUtilsPlugin } from './plugin' -import { isProcedureUtilsOptions, mergeProcedureUtilsOptions, ProcedureUtils } from './procedure-utils' -import { SharedUtils } from './shared-utils' + +import type { OperationKeyBaseOptions } from './key.ts' +import type { RouterUtilsPlugin } from './plugin.ts' +import { CompositeRouterUtilsPlugin } from './plugin.ts' +import type { + ProcedureUtilsInfiniteInterceptor, + ProcedureUtilsLiveInterceptor, + ProcedureUtilsMutationInterceptor, + ProcedureUtilsOptions, + ProcedureUtilsQueryInterceptor, + ProcedureUtilsStreamedInterceptor, +} from './procedure-utils.ts' +import { + isProcedureUtilsOptions, + mergeProcedureUtilsOptions, + ProcedureUtils, +} from './procedure-utils.ts' +import { SharedUtils } from './shared-utils.ts' /** * The utils shape derived from a client: procedure clients map to procedure @@ -15,18 +27,20 @@ import { SharedUtils } from './shared-utils' * * @see {@link https://orpc.dev/docs/integrations/pinia-colada | Pinia Colada Integration} */ -export type RouterUtils - = T extends Client +export type RouterUtils = + T extends Client ? Public> : { - [K in keyof T]: T[K] extends AnyNestedClient ? RouterUtils : never - } & Public> + [K in keyof T]: T[K] extends AnyNestedClient ? RouterUtils : never + } & Public> -export type RouterUtilsScoped - = T extends Client +export type RouterUtilsScoped = + T extends Client ? ProcedureUtilsOptions : { - [K in keyof T]?: (T[K] extends AnyNestedClient ? RouterUtilsScoped : never) | undefined + [K in keyof T]?: + | (T[K] extends AnyNestedClient ? RouterUtilsScoped : never) + | undefined } export interface RouterUtilsOptions extends OperationKeyBaseOptions { @@ -40,27 +54,52 @@ export interface RouterUtilsOptions extends Operation /** * Interceptors that intercept query inside .queryOptions */ - queryInterceptors?: ProcedureUtilsQueryInterceptor, unknown, unknown, InferClientError>[] | undefined + queryInterceptors?: + | ProcedureUtilsQueryInterceptor, unknown, unknown, InferClientError>[] + | undefined /** * Interceptors that intercept query inside .streamedOptions */ - streamedInterceptors?: ProcedureUtilsStreamedInterceptor, unknown, unknown, InferClientError>[] | undefined + streamedInterceptors?: + | ProcedureUtilsStreamedInterceptor< + InferClientContext, + unknown, + unknown, + InferClientError + >[] + | undefined /** * Interceptors that intercept query inside .liveOptions */ - liveInterceptors?: ProcedureUtilsLiveInterceptor, unknown, unknown, InferClientError>[] | undefined + liveInterceptors?: + | ProcedureUtilsLiveInterceptor, unknown, unknown, InferClientError>[] + | undefined /** * Interceptors that intercept query inside .infiniteOptions */ - infiniteInterceptors?: ProcedureUtilsInfiniteInterceptor, unknown, unknown, InferClientError>[] | undefined + infiniteInterceptors?: + | ProcedureUtilsInfiniteInterceptor< + InferClientContext, + unknown, + unknown, + InferClientError + >[] + | undefined /** * Interceptors that intercept mutation inside .mutationOptions */ - mutationInterceptors?: ProcedureUtilsMutationInterceptor, unknown, unknown, InferClientError>[] | undefined + mutationInterceptors?: + | ProcedureUtilsMutationInterceptor< + InferClientContext, + unknown, + unknown, + InferClientError + >[] + | undefined /** * Per-procedure options following the shape of the router. @@ -101,24 +140,32 @@ function createRouterUtilsInternal( ): RouterUtils { const path = toArray(options.path) - const utils = typeof client === 'function' && (options.scoped === undefined || isProcedureUtilsOptions(options.scoped)) - ? bindMethods(new ProcedureUtils( - path, - client, - plugin.initProcedureOptions(path, mergeProcedureUtilsOptions( - pick(options, [ - 'prefix', - 'serializer', - 'queryInterceptors', - 'streamedInterceptors', - 'liveInterceptors', - 'infiniteInterceptors', - 'mutationInterceptors', - ]) as ProcedureUtilsOptions, - options.scoped ?? {}, - )), - ), { unbound: ['call'] }) - : bindMethods(new SharedUtils(path, options)) + const utils = + typeof client === 'function' && + (options.scoped === undefined || isProcedureUtilsOptions(options.scoped)) + ? bindMethods( + new ProcedureUtils( + path, + client, + plugin.initProcedureOptions( + path, + mergeProcedureUtilsOptions( + pick(options, [ + 'prefix', + 'serializer', + 'queryInterceptors', + 'streamedInterceptors', + 'liveInterceptors', + 'infiniteInterceptors', + 'mutationInterceptors', + ]) as ProcedureUtilsOptions, + options.scoped ?? {}, + ), + ), + ), + { unbound: ['call'] }, + ) + : bindMethods(new SharedUtils(path, options)) const cache = new Map() @@ -139,23 +186,30 @@ function createRouterUtilsInternal( let result = cache.get(prop) if (result === undefined) { - const nextUtils = createRouterUtilsInternal(nextClient as any, { - ...options, - path: [...path, prop], - scoped: (options.scoped as undefined | Record['scoped']>)?.[prop], - }, plugin) - - result = typeof value !== 'function' - ? nextUtils - : new Proxy(value, { - get(target, prop) { - if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) { - return Reflect.get(target, prop) - } - - return Reflect.get(nextUtils, prop) - }, - }) + const nextUtils = createRouterUtilsInternal( + nextClient as any, + { + ...options, + path: [...path, prop], + scoped: ( + options.scoped as undefined | Record['scoped']> + )?.[prop], + }, + plugin, + ) + + result = + typeof value !== 'function' + ? nextUtils + : new Proxy(value, { + get(target, prop) { + if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) { + return Reflect.get(target, prop) + } + + return Reflect.get(nextUtils, prop) + }, + }) cache.set(prop, result) } diff --git a/packages/pinia-colada/src/shared-utils.test-d.ts b/packages/pinia-colada/src/shared-utils.test-d.ts index 683614ebdf..7ab719d82a 100644 --- a/packages/pinia-colada/src/shared-utils.test-d.ts +++ b/packages/pinia-colada/src/shared-utils.test-d.ts @@ -1,7 +1,8 @@ import type { Public } from '@orpc/shared' -import type { SharedUtils } from './shared-utils' import { ref } from 'vue' +import type { SharedUtils } from './shared-utils.ts' + describe('SharedUtils', () => { const utils = {} as Public> diff --git a/packages/pinia-colada/src/shared-utils.test.ts b/packages/pinia-colada/src/shared-utils.test.ts index 1648ebd3a7..1eb9a7a3c9 100644 --- a/packages/pinia-colada/src/shared-utils.test.ts +++ b/packages/pinia-colada/src/shared-utils.test.ts @@ -1,6 +1,7 @@ import { RPCJsonSerializer } from '@orpc/client' -import * as KeyModule from './key' -import { SharedUtils } from './shared-utils' + +import * as KeyModule from './key.ts' +import { SharedUtils } from './shared-utils.ts' const generateOperationKeySpy = vi.spyOn(KeyModule, 'generateOperationKey') @@ -12,23 +13,29 @@ describe('sharedUtils', () => { const utils = new SharedUtils(['path'], {}) it('.key', () => { - expect( - utils.key({ input: { search: '__search__' }, type: 'query' }), - ).toBe(generateOperationKeySpy.mock.results[0]!.value) + expect(utils.key({ input: { search: '__search__' }, type: 'query' })).toBe( + generateOperationKeySpy.mock.results[0]!.value, + ) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['path'], { input: { search: '__search__' }, type: 'query', prefix: undefined }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['path'], { + input: { search: '__search__' }, + type: 'query', + prefix: undefined, + }) }) it('.key with key options', () => { const serializer = new RPCJsonSerializer() const keyedUtils = new SharedUtils(['path'], { prefix: '__prefix__', serializer }) - expect( - keyedUtils.key({ type: 'query' }), - ).toBe(generateOperationKeySpy.mock.results[0]!.value) + expect(keyedUtils.key({ type: 'query' })).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['path'], { type: 'query', prefix: '__prefix__', serializer }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['path'], { + type: 'query', + prefix: '__prefix__', + serializer, + }) }) }) diff --git a/packages/pinia-colada/src/shared-utils.ts b/packages/pinia-colada/src/shared-utils.ts index 35be72709f..6d6de74435 100644 --- a/packages/pinia-colada/src/shared-utils.ts +++ b/packages/pinia-colada/src/shared-utils.ts @@ -1,6 +1,6 @@ -import type { OperationKey, OperationKeyBaseOptions, OperationKeyOptions } from './key' -import type { OperationType } from './types' -import { generateOperationKey } from './key' +import type { OperationKey, OperationKeyBaseOptions, OperationKeyOptions } from './key.ts' +import { generateOperationKey } from './key.ts' +import type { OperationType } from './types.ts' export class SharedUtils { constructor( @@ -13,7 +13,13 @@ export class SharedUtils { * * @see {@link https://orpc.dev/docs/integrations/pinia-colada#querymutation-key | Pinia Colada Integration - Query/Mutation Key} */ - key(options: Omit, keyof OperationKeyBaseOptions> = {}): OperationKey { - return generateOperationKey(this.path, { ...options, prefix: this.options.prefix, serializer: this.options.serializer }) + key( + options: Omit, keyof OperationKeyBaseOptions> = {}, + ): OperationKey { + return generateOperationKey(this.path, { + ...options, + prefix: this.options.prefix, + serializer: this.options.serializer, + }) } } diff --git a/packages/pinia-colada/src/stream-query.test.tsx b/packages/pinia-colada/src/stream-query.test.tsx index 303104d10d..7de38d6848 100644 --- a/packages/pinia-colada/src/stream-query.test.tsx +++ b/packages/pinia-colada/src/stream-query.test.tsx @@ -1,32 +1,33 @@ -import type { UseQueryOptions } from '@pinia/colada' import { promiseWithResolvers } from '@orpc/shared' +import type { UseQueryOptions } from '@pinia/colada' import { PiniaColada, useQuery, useQueryCache } from '@pinia/colada' import { mount } from '@vue/test-utils' import { createPinia } from 'pinia' import { defineComponent } from 'vue' -import { serializableStreamedQuery } from './stream-query' + +import { serializableStreamedQuery } from './stream-query.ts' /** * Mounts a component that runs a real Pinia Colada `useQuery`, * so tests can observe exactly what users see. */ function mountQuery(options: UseQueryOptions) { - return mount(defineComponent({ - setup() { - const queryCache = useQueryCache() - const query = useQuery(options) - - return { query, queryCache } - }, - render: () => null, - }), { - global: { - plugins: [ - createPinia(), - PiniaColada, - ], + return mount( + defineComponent({ + setup() { + const queryCache = useQueryCache() + const query = useQuery(options) + + return { query, queryCache } + }, + render: () => null, + }), + { + global: { + plugins: [createPinia(), PiniaColada], + }, }, - }) + ) } /** @@ -38,7 +39,7 @@ function createChunkController() { let notify: (() => void) | undefined return { - async* stream() { + async *stream() { while (true) { while (buffer.length === 0) { await new Promise((resolve) => { @@ -100,7 +101,8 @@ describe('serializableStreamedQuery', () => { releaseSecond = resolve }) - const queryFn = vi.fn() + const queryFn = vi + .fn() .mockImplementationOnce(() => first.stream()) .mockImplementationOnce(async () => { await secondReady @@ -139,7 +141,8 @@ describe('serializableStreamedQuery', () => { const first = createChunkController() const second = createChunkController() - const queryFn = vi.fn() + const queryFn = vi + .fn() .mockImplementationOnce(() => first.stream()) .mockImplementationOnce(() => second.stream()) @@ -168,7 +171,8 @@ describe('serializableStreamedQuery', () => { const first = createChunkController() const second = createChunkController() - const queryFn = vi.fn() + const queryFn = vi + .fn() .mockImplementationOnce(() => first.stream()) .mockImplementationOnce(() => second.stream()) @@ -214,7 +218,8 @@ describe('serializableStreamedQuery', () => { const first = createChunkController() const second = createChunkController() - const queryFn = vi.fn() + const queryFn = vi + .fn() .mockImplementationOnce(() => first.stream()) .mockImplementationOnce(() => second.stream()) @@ -251,7 +256,8 @@ describe('serializableStreamedQuery', () => { const firstClosed = vi.spyOn(firstStream, 'return') const second = createChunkController() - const queryFn = vi.fn() + const queryFn = vi + .fn() .mockImplementationOnce(async () => { await firstReady.promise return firstStream diff --git a/packages/pinia-colada/src/stream-query.ts b/packages/pinia-colada/src/stream-query.ts index 7c99e23583..f754c452ad 100644 --- a/packages/pinia-colada/src/stream-query.ts +++ b/packages/pinia-colada/src/stream-query.ts @@ -1,5 +1,6 @@ import type { Promisable } from '@orpc/shared' -import type { UseQueryFnContext } from './types' + +import type { UseQueryFnContext } from './types.ts' export interface SerializableStreamedQueryOptions { /** @@ -31,10 +32,11 @@ export interface SerializableStreamedQueryOptions { * - The output is predictable */ export function serializableStreamedQuery( - queryFn: ( - context: UseQueryFnContext, - ) => Promisable>, - { refetchMode = 'reset', maxChunks = Number.POSITIVE_INFINITY }: SerializableStreamedQueryOptions = {}, + queryFn: (context: UseQueryFnContext) => Promisable>, + { + refetchMode = 'reset', + maxChunks = Number.POSITIVE_INFINITY, + }: SerializableStreamedQueryOptions = {}, ): (context: UseQueryFnContext) => Promise { return async (context) => { const { entry, signal } = context @@ -55,9 +57,8 @@ export function serializableStreamedQuery( const shouldUpdateCacheDuringStream = !hasPreviousData || refetchMode !== 'replace' - let result: T[] = hasPreviousData && refetchMode === 'append' - ? limitArraySize(previousData, maxChunks) - : [] + let result: T[] = + hasPreviousData && refetchMode === 'append' ? limitArraySize(previousData, maxChunks) : [] if (shouldUpdateCacheDuringStream) { entry.state.value = { status: 'success', data: result, error: null } as any diff --git a/packages/pinia-colada/src/types.ts b/packages/pinia-colada/src/types.ts index ea72fba95a..294eac0371 100644 --- a/packages/pinia-colada/src/types.ts +++ b/packages/pinia-colada/src/types.ts @@ -1,6 +1,18 @@ import type { ClientContext } from '@orpc/client' -import type { DefineInfiniteQueryOptions, DefineInfiniteQueryOptionsTagged, DefineMutationOptionsTagged, DefineQueryOptions, DefineQueryOptionsTagged, EntryKey, UseInfiniteQueryData, UseMutationOptions, UseMutationOptionsGlobal, UseQueryOptions } from '@pinia/colada' -import type { SerializableStreamedQueryOptions } from './stream-query' +import type { + DefineInfiniteQueryOptions, + DefineInfiniteQueryOptionsTagged, + DefineMutationOptionsTagged, + DefineQueryOptions, + DefineQueryOptionsTagged, + EntryKey, + UseInfiniteQueryData, + UseMutationOptions, + UseMutationOptionsGlobal, + UseQueryOptions, +} from '@pinia/colada' + +import type { SerializableStreamedQueryOptions } from './stream-query.ts' export type InferStreamedQueryOutput = TOutput extends AsyncIterable ? U[] : never export type InferLiveQueryOutput = TOutput extends AsyncIterable ? U : never @@ -19,7 +31,9 @@ export type UseMutationFnContext = Parameters - = | (undefined extends TInput ? { input?: TInput } : { input: TInput }) - | { key: EntryKey } - -export type QueryOptionsIn - = & (undefined extends TInput ? { input?: TInput } : { input: TInput }) - & (object extends TClientContext ? { context?: TClientContext | undefined } : { context: TClientContext }) - & Omit, 'key' | 'query'> - & Partial, 'key' | 'query'>> - -export type QueryOptionsOut = DefineQueryOptionsTagged - -export type StreamedKeyOptions - = | ((undefined extends TInput ? { input?: TInput } : { input: TInput }) & { fnOptions?: SerializableStreamedQueryOptions | undefined }) - | { key: EntryKey } - -export type StreamedOptionsIn - = & QueryOptionsIn - & { fnOptions?: SerializableStreamedQueryOptions | undefined } - -export type StreamedOptionsOut = QueryOptionsOut - -export type InfiniteKeyOptions - = | Pick, 'input' | 'initialPageParam'> - | { key: EntryKey } - -export type InfiniteOptionsIn | undefined> - = & { input: (pageParam: TPageParam) => TInput } - & (object extends TClientContext ? { context?: TClientContext | undefined } : { context: TClientContext }) - & Omit, 'key' | 'query'> - & Partial, 'key' | 'query'>> +export type QueryKeyOptions = + | (undefined extends TInput ? { input?: TInput } : { input: TInput }) + | { key: EntryKey } + +export type QueryOptionsIn< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, + TInitialData extends TOutput | undefined, +> = (undefined extends TInput ? { input?: TInput } : { input: TInput }) & + (object extends TClientContext + ? { context?: TClientContext | undefined } + : { context: TClientContext }) & + Omit, 'key' | 'query'> & + Partial, 'key' | 'query'>> + +export type QueryOptionsOut< + TOutput, + TError, + TInitialData extends TOutput | undefined, +> = DefineQueryOptionsTagged + +export type StreamedKeyOptions = + | ((undefined extends TInput ? { input?: TInput } : { input: TInput }) & { + fnOptions?: SerializableStreamedQueryOptions | undefined + }) + | { key: EntryKey } + +export type StreamedOptionsIn< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, + TInitialData extends TOutput | undefined, +> = QueryOptionsIn & { + fnOptions?: SerializableStreamedQueryOptions | undefined +} -export type InfiniteOptionsOut | undefined> = DefineInfiniteQueryOptionsTagged +export type StreamedOptionsOut< + TOutput, + TError, + TInitialData extends TOutput | undefined, +> = QueryOptionsOut + +export type InfiniteKeyOptions = + | Pick, 'input' | 'initialPageParam'> + | { key: EntryKey } + +export type InfiniteOptionsIn< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, + TPageParam, + TInitialData extends UseInfiniteQueryData | undefined, +> = { input: (pageParam: TPageParam) => TInput } & (object extends TClientContext + ? { context?: TClientContext | undefined } + : { context: TClientContext }) & + Omit, 'key' | 'query'> & + Partial< + Pick, 'key' | 'query'> + > + +export type InfiniteOptionsOut< + TOutput, + TError, + TPageParam, + TInitialData extends UseInfiniteQueryData | undefined, +> = DefineInfiniteQueryOptionsTagged export type MutationKeyOptions = Pick, 'key'> -export type MutationOptionsIn> - = & (object extends TClientContext ? { context?: TClientContext | undefined } : { context: TClientContext }) - & Omit, 'mutation'> - & Partial, 'mutation'>> - -export type MutationOptionsOut> = DefineMutationOptionsTagged +export type MutationOptionsIn< + TClientContext extends ClientContext, + TInput, + TOutput, + TError, + TMutationContext extends Record, +> = (object extends TClientContext + ? { context?: TClientContext | undefined } + : { context: TClientContext }) & + Omit, 'mutation'> & + Partial, 'mutation'>> + +export type MutationOptionsOut< + TInput, + TOutput, + TError, + TMutationContext extends Record, +> = DefineMutationOptionsTagged diff --git a/packages/pinia-colada/tests/__shared__/orpc.ts b/packages/pinia-colada/tests/__shared__/orpc.ts index 1092aff549..30aa5925eb 100644 --- a/packages/pinia-colada/tests/__shared__/orpc.ts +++ b/packages/pinia-colada/tests/__shared__/orpc.ts @@ -1,13 +1,14 @@ -import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' import { asyncIteratorObject, os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { PiniaColada } from '@pinia/colada' import { mount as baseMount } from '@vue/test-utils' import { createPinia } from 'pinia' -import z from 'zod' -import { createPiniaColadaUtils } from '../../src' +import * as z from 'zod' + +import { createPiniaColadaUtils } from '../../src/index.ts' export const router = { ping: os @@ -20,18 +21,22 @@ export const router = { .errors({ OVERRIDE: {} }) .input(z.object({ input: z.number() }).optional()) .output(asyncIteratorObject(z.object({ output: z.string() }))) - .handler(vi.fn(async function* ({ input }) { - for (let i = 0; i < (input?.input ?? 0); i++) { - yield { output: i.toString() } - } - })), + .handler( + vi.fn(async function* ({ input }) { + for (let i = 0; i < (input?.input ?? 0); i++) { + yield { output: i.toString() } + } + }), + ), list: os .input(z.object({ cursor: z.number() })) .output(z.object({ items: z.array(z.string()), next: z.number().nullable() })) - .handler(vi.fn(({ input }) => ({ - items: [`item-${input.cursor}`], - next: input.cursor < 2 ? input.cursor + 1 : null, - }))), + .handler( + vi.fn(({ input }) => ({ + items: [`item-${input.cursor}`], + next: input.cursor < 2 ? input.cursor + 1 : null, + })), + ), nested: { ping: os .errors({ BASE: { data: z.object({ output: z.string() }) }, OVERRIDE: {} }) @@ -45,23 +50,22 @@ export const router = { const handler = new RPCHandler(router) // prefer createORPCClient over createRouterClient for more close realistic -export const client: RouterClient = createORPCClient(new RPCLink({ - origin: 'http://localhost', - fetch: async (url, init) => { - const { response } = await handler.handle(new Request(url, init)) - return response ?? new Response('Not Found', { status: 404 }) - }, -})) +export const client: RouterClient = createORPCClient( + new RPCLink({ + origin: 'http://localhost', + fetch: async (url, init) => { + const { response } = await handler.handle(new Request(url, init)) + return response ?? new Response('Not Found', { status: 404 }) + }, + }), +) export const orpc = createPiniaColadaUtils(client) export const mount: typeof baseMount = (component, options) => { return baseMount(component, { global: { - plugins: [ - createPinia(), - PiniaColada, - ], + plugins: [createPinia(), PiniaColada], }, ...options, }) diff --git a/packages/pinia-colada/tests/e2e.test-d.ts b/packages/pinia-colada/tests/e2e.test-d.ts index 145bfb13c0..7cd0caebee 100644 --- a/packages/pinia-colada/tests/e2e.test-d.ts +++ b/packages/pinia-colada/tests/e2e.test-d.ts @@ -1,12 +1,13 @@ import { isDefinedError } from '@orpc/client' import { useInfiniteQuery, useMutation, useQuery, useQueryCache } from '@pinia/colada' import { computed } from 'vue' -import { client, orpc } from './__shared__/orpc' + +import { client, orpc } from './__shared__/orpc.ts' it('.key', () => { const queryCache = useQueryCache() - queryCache.invalidateQueries({ + void queryCache.invalidateQueries({ key: orpc.nested.key(), }) @@ -21,9 +22,11 @@ it('.call', () => { describe('.queryOptions', () => { it('useQuery', () => { - const query = useQuery(() => orpc.ping.queryOptions({ - input: { input: 123 }, - })) + const query = useQuery(() => + orpc.ping.queryOptions({ + input: { input: 123 }, + }), + ) if (isDefinedError(query.error.value) && query.error.value.code === 'OVERRIDE') { expectTypeOf(query.error.value.data).toEqualTypeOf() @@ -31,25 +34,31 @@ describe('.queryOptions', () => { expectTypeOf(query.data.value).toEqualTypeOf<{ output: string } | undefined>() - useQuery(orpc.ping.queryOptions({ - // @ts-expect-error --- ref/computed input is not allowed - input: computed(() => ({ input: 123 })), - })) - - useQuery(orpc.ping.queryOptions({ - input: { - // @ts-expect-error --- input is invalid - input: '123', - }, - })) - - useQuery(orpc.ping.queryOptions({ - input: { input: 123 }, - context: { - // @ts-expect-error --- cache is invalid - cache: 123, - }, - })) + useQuery( + orpc.ping.queryOptions({ + // @ts-expect-error --- ref/computed input is not allowed + input: computed(() => ({ input: 123 })), + }), + ) + + useQuery( + orpc.ping.queryOptions({ + input: { + // @ts-expect-error --- input is invalid + input: '123', + }, + }), + ) + + useQuery( + orpc.ping.queryOptions({ + input: { input: 123 }, + context: { + // @ts-expect-error --- cache is invalid + cache: 123, + }, + }), + ) }) it('works with defineQueryOptions', () => { @@ -69,41 +78,51 @@ describe('.streamedOptions & .liveOptions', () => { expectTypeOf(live.data.value).toEqualTypeOf<{ output: string } | undefined>() - useQuery(orpc.stream.streamedOptions({ - // @ts-expect-error --- input is invalid - input: { input: 'INVALID' }, - })) + useQuery( + orpc.stream.streamedOptions({ + // @ts-expect-error --- input is invalid + input: { input: 'INVALID' }, + }), + ) }) }) describe('.infiniteOptions', () => { it('useInfiniteQuery', () => { - const query = useInfiniteQuery(() => orpc.list.infiniteOptions({ - input: (cursor: number) => ({ cursor }), - initialPageParam: 0, - getNextPageParam: lastPage => lastPage.next, - })) - - expectTypeOf(query.data.value?.pages).toEqualTypeOf<{ items: string[], next: number | null }[] | undefined>() - - useInfiniteQuery(() => orpc.list.infiniteOptions({ - // @ts-expect-error --- input is invalid - input: (cursor: number) => ({ cursor: 'invalid' }), - initialPageParam: 0, - getNextPageParam: lastPage => lastPage.next, - })) + const query = useInfiniteQuery(() => + orpc.list.infiniteOptions({ + input: (cursor: number) => ({ cursor }), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.next, + }), + ) + + expectTypeOf(query.data.value?.pages).toEqualTypeOf< + { items: string[]; next: number | null }[] | undefined + >() + + useInfiniteQuery(() => + orpc.list.infiniteOptions({ + // @ts-expect-error --- input is invalid + input: (cursor: number) => ({ cursor: 'invalid' }), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.next, + }), + ) }) }) describe('.mutationOptions', () => { it('useMutation', async () => { - const mutation = useMutation(orpc.ping.mutationOptions({ - onError(error, variables) { - if (isDefinedError(error) && error.code === 'BASE') { - expectTypeOf(error.data).toEqualTypeOf<{ output: string }>() - } - }, - })) + const mutation = useMutation( + orpc.ping.mutationOptions({ + onError(error, variables) { + if (isDefinedError(error) && error.code === 'BASE') { + expectTypeOf(error.data).toEqualTypeOf<{ output: string }>() + } + }, + }), + ) if (isDefinedError(mutation.error.value) && mutation.error.value.code === 'OVERRIDE') { expectTypeOf(mutation.error.value.data).toEqualTypeOf() @@ -113,16 +132,18 @@ describe('.mutationOptions', () => { mutation.mutate({ input: 123 }) - mutation.mutateAsync({ + void mutation.mutateAsync({ // @ts-expect-error --- input is invalid input: 'INVALID', }) - useMutation(orpc.ping.mutationOptions({ - context: { - // @ts-expect-error --- cache is invalid - cache: 123, - }, - })) + useMutation( + orpc.ping.mutationOptions({ + context: { + // @ts-expect-error --- cache is invalid + cache: 123, + }, + }), + ) }) }) diff --git a/packages/pinia-colada/tests/e2e.test.tsx b/packages/pinia-colada/tests/e2e.test.tsx index 223da90e3d..8a166add93 100644 --- a/packages/pinia-colada/tests/e2e.test.tsx +++ b/packages/pinia-colada/tests/e2e.test.tsx @@ -1,8 +1,9 @@ import { isDefinedError, ORPCError } from '@orpc/client' import { useInfiniteQuery, useMutation, useQuery, useQueryCache } from '@pinia/colada' import { defineComponent, ref } from 'vue' -import { createPiniaColadaUtils, PINIA_COLADA_OPERATION_CONTEXT_SYMBOL } from '../src' -import { client, mount, orpc, router } from './__shared__/orpc' + +import { createPiniaColadaUtils, PINIA_COLADA_OPERATION_CONTEXT_SYMBOL } from '../src/index.ts' +import { client, mount, orpc, router } from './__shared__/orpc.ts' beforeEach(() => { vi.clearAllMocks() @@ -13,35 +14,39 @@ it('case: call directly', async () => { }) it('case: with useQuery', async () => { - const mounted = mount(defineComponent({ - setup() { - const id = ref(123) + const mounted = mount( + defineComponent({ + setup() { + const id = ref(123) - const queryCache = useQueryCache() - const query = useQuery(() => orpc.nested.ping.queryOptions({ input: { input: id.value } })) + const queryCache = useQueryCache() + const query = useQuery(() => orpc.nested.ping.queryOptions({ input: { input: id.value } })) - const setId = (value: number) => { - id.value = value - } + const setId = (value: number) => { + id.value = value + } - return { query, queryCache, setId } - }, - render: () => null, - })) + return { query, queryCache, setId } + }, + render: () => null, + }), + ) // I don't know why but we should put error case in the top of the test or it will fail by `Unhandled Rejection` vi.mocked(router.nested.ping['~orpc'].handler).mockRejectedValueOnce(new ORPCError('OVERRIDE')) - await vi.waitFor( - () => expect(mounted.vm.query.error.value).toSatisfy((e: any) => isDefinedError(e) && e.code === 'OVERRIDE'), + await vi.waitFor(() => + expect(mounted.vm.query.error.value).toSatisfy( + (e: any) => isDefinedError(e) && e.code === 'OVERRIDE', + ), ) - mounted.vm.queryCache.invalidateQueries({ key: orpc.ping.key() }) + void mounted.vm.queryCache.invalidateQueries({ key: orpc.ping.key() }) expect(mounted.vm.query.isLoading.value).toEqual(false) - mounted.vm.queryCache.invalidateQueries({ key: orpc.nested.pong.key() }) + void mounted.vm.queryCache.invalidateQueries({ key: orpc.nested.pong.key() }) expect(mounted.vm.query.isLoading.value).toEqual(false) - mounted.vm.queryCache.invalidateQueries({ key: orpc.nested.key() }) + void mounted.vm.queryCache.invalidateQueries({ key: orpc.nested.key() }) expect(mounted.vm.query.isLoading.value).toEqual(true) await vi.waitFor(() => expect(mounted.vm.query.data.value).toEqual({ output: '123' })) @@ -56,46 +61,52 @@ it('case: with useQuery', async () => { }) it('case: with streamed/useQuery', async () => { - const mounted = mount(defineComponent({ - setup() { - const input = ref(2) - - const queryCache = useQueryCache() - const query = useQuery(() => orpc.stream.streamedOptions({ - input: { input: input.value }, - fnOptions: { refetchMode: 'append', maxChunks: 3 }, - })) - - const setInput = (value: number) => { - input.value = value - } - - return { query, queryCache, setInput } - }, - render: () => null, - })) + const mounted = mount( + defineComponent({ + setup() { + const input = ref(2) + + const queryCache = useQueryCache() + const query = useQuery(() => + orpc.stream.streamedOptions({ + input: { input: input.value }, + fnOptions: { refetchMode: 'append', maxChunks: 3 }, + }), + ) + + const setInput = (value: number) => { + input.value = value + } + + return { query, queryCache, setInput } + }, + render: () => null, + }), + ) // I don't know why but we should put error case in the top of the test or it will fail by `Unhandled Rejection` vi.mocked(router.stream['~orpc'].handler).mockRejectedValueOnce(new ORPCError('OVERRIDE')) - await vi.waitFor( - () => expect(mounted.vm.query.error.value).toSatisfy((e: any) => isDefinedError(e) && e.code === 'OVERRIDE'), + await vi.waitFor(() => + expect(mounted.vm.query.error.value).toSatisfy( + (e: any) => isDefinedError(e) && e.code === 'OVERRIDE', + ), ) // next fetch streams chunk by chunk, gated so intermediate states are observable const releases: (() => void)[] = [] vi.mocked(router.stream['~orpc'].handler).mockImplementationOnce(async function* () { yield { output: '0' } - await new Promise(resolve => releases.push(resolve)) + await new Promise((resolve) => releases.push(resolve)) yield { output: '1' } } as any) - mounted.vm.queryCache.invalidateQueries({ key: orpc.ping.key() }) + void mounted.vm.queryCache.invalidateQueries({ key: orpc.ping.key() }) expect(mounted.vm.query.isLoading.value).toEqual(false) - mounted.vm.queryCache.invalidateQueries({ key: orpc.stream.key({ type: 'live' }) }) + void mounted.vm.queryCache.invalidateQueries({ key: orpc.stream.key({ type: 'live' }) }) expect(mounted.vm.query.isLoading.value).toEqual(false) - mounted.vm.queryCache.invalidateQueries({ key: orpc.stream.key({ type: 'streamed' }) }) + void mounted.vm.queryCache.invalidateQueries({ key: orpc.stream.key({ type: 'streamed' }) }) expect(mounted.vm.query.isLoading.value).toEqual(true) // the first chunk is visible while the stream is still open @@ -104,27 +115,30 @@ it('case: with streamed/useQuery', async () => { releases[0]!() - await vi.waitFor(() => expect(mounted.vm.query.data.value).toEqual([ - { output: '0' }, - { output: '1' }, - ])) + await vi.waitFor(() => + expect(mounted.vm.query.data.value).toEqual([{ output: '0' }, { output: '1' }]), + ) await vi.waitFor(() => expect(mounted.vm.query.isLoading.value).toEqual(false)) expect( - mounted.vm.queryCache.getQueryData(orpc.stream.streamedKey({ - input: { input: 2 }, - fnOptions: { refetchMode: 'append', maxChunks: 3 }, - })), + mounted.vm.queryCache.getQueryData( + orpc.stream.streamedKey({ + input: { input: 2 }, + fnOptions: { refetchMode: 'append', maxChunks: 3 }, + }), + ), ).toEqual([{ output: '0' }, { output: '1' }]) // append mode: refetching appends new chunks, limited by maxChunks await mounted.vm.query.refetch() - await vi.waitFor(() => expect(mounted.vm.query.data.value).toEqual([ - { output: '1' }, - { output: '0' }, - { output: '1' }, - ])) + await vi.waitFor(() => + expect(mounted.vm.query.data.value).toEqual([ + { output: '1' }, + { output: '0' }, + { output: '1' }, + ]), + ) // changing the input targets a fresh entry mounted.vm.setInput(1) @@ -133,34 +147,38 @@ it('case: with streamed/useQuery', async () => { }) it('case: with live/useQuery', async () => { - const mounted = mount(defineComponent({ - setup() { - const queryCache = useQueryCache() - const query = useQuery(orpc.stream.liveOptions({ input: { input: 2 } })) + const mounted = mount( + defineComponent({ + setup() { + const queryCache = useQueryCache() + const query = useQuery(orpc.stream.liveOptions({ input: { input: 2 } })) - return { query, queryCache } - }, - render: () => null, - })) + return { query, queryCache } + }, + render: () => null, + }), + ) // I don't know why but we should put error case in the top of the test or it will fail by `Unhandled Rejection` vi.mocked(router.stream['~orpc'].handler).mockRejectedValueOnce(new ORPCError('OVERRIDE')) - await vi.waitFor( - () => expect(mounted.vm.query.error.value).toSatisfy((e: any) => isDefinedError(e) && e.code === 'OVERRIDE'), + await vi.waitFor(() => + expect(mounted.vm.query.error.value).toSatisfy( + (e: any) => isDefinedError(e) && e.code === 'OVERRIDE', + ), ) // next fetch streams chunk by chunk, gated so intermediate states are observable const releases: (() => void)[] = [] vi.mocked(router.stream['~orpc'].handler).mockImplementationOnce(async function* () { yield { output: '0' } - await new Promise(resolve => releases.push(resolve)) + await new Promise((resolve) => releases.push(resolve)) yield { output: '1' } } as any) - mounted.vm.queryCache.invalidateQueries({ key: orpc.stream.key({ type: 'streamed' }) }) + void mounted.vm.queryCache.invalidateQueries({ key: orpc.stream.key({ type: 'streamed' }) }) expect(mounted.vm.query.isLoading.value).toEqual(false) - mounted.vm.queryCache.invalidateQueries({ key: orpc.stream.key({ type: 'live' }) }) + void mounted.vm.queryCache.invalidateQueries({ key: orpc.stream.key({ type: 'live' }) }) expect(mounted.vm.query.isLoading.value).toEqual(true) // each chunk replaces the previous value while the stream is still open @@ -172,45 +190,53 @@ it('case: with live/useQuery', async () => { await vi.waitFor(() => expect(mounted.vm.query.data.value).toEqual({ output: '1' })) await vi.waitFor(() => expect(mounted.vm.query.isLoading.value).toEqual(false)) - expect( - mounted.vm.queryCache.getQueryData(orpc.stream.liveKey({ input: { input: 2 } })), - ).toEqual({ output: '1' }) + expect(mounted.vm.queryCache.getQueryData(orpc.stream.liveKey({ input: { input: 2 } }))).toEqual({ + output: '1', + }) }) it('case: with useInfiniteQuery', async () => { - const mounted = mount(defineComponent({ - setup() { - const queryCache = useQueryCache() - const query = useInfiniteQuery(() => orpc.list.infiniteOptions({ - input: (cursor: number) => ({ cursor }), - initialPageParam: 0, - getNextPageParam: lastPage => lastPage.next, - })) - - return { query, queryCache } - }, - render: () => null, - })) - - await vi.waitFor(() => expect(mounted.vm.query.data.value?.pages).toEqual([ - { items: ['item-0'], next: 1 }, - ])) + const mounted = mount( + defineComponent({ + setup() { + const queryCache = useQueryCache() + const query = useInfiniteQuery(() => + orpc.list.infiniteOptions({ + input: (cursor: number) => ({ cursor }), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.next, + }), + ) + + return { query, queryCache } + }, + render: () => null, + }), + ) + + await vi.waitFor(() => + expect(mounted.vm.query.data.value?.pages).toEqual([{ items: ['item-0'], next: 1 }]), + ) expect(mounted.vm.query.hasNextPage.value).toEqual(true) await mounted.vm.query.loadNextPage() await mounted.vm.query.loadNextPage() - await vi.waitFor(() => expect(mounted.vm.query.data.value?.pages).toEqual([ - { items: ['item-0'], next: 1 }, - { items: ['item-1'], next: 2 }, - { items: ['item-2'], next: null }, - ])) + await vi.waitFor(() => + expect(mounted.vm.query.data.value?.pages).toEqual([ + { items: ['item-0'], next: 1 }, + { items: ['item-1'], next: 2 }, + { items: ['item-2'], next: null }, + ]), + ) expect(mounted.vm.query.hasNextPage.value).toEqual(false) expect( - mounted.vm.queryCache.getQueryData(orpc.list.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 0 })), + mounted.vm.queryCache.getQueryData( + orpc.list.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 0 }), + ), ).toEqual({ pages: [ { items: ['item-0'], next: 1 }, @@ -222,14 +248,16 @@ it('case: with useInfiniteQuery', async () => { }) it('case: with useMutation', async () => { - const mounted = mount(defineComponent({ - setup() { - const mutation = useMutation(orpc.nested.ping.mutationOptions()) + const mounted = mount( + defineComponent({ + setup() { + const mutation = useMutation(orpc.nested.ping.mutationOptions()) - return { mutation } - }, - render: () => null, - })) + return { mutation } + }, + render: () => null, + }), + ) mounted.vm.mutation.mutate({ input: 123 }) @@ -249,19 +277,27 @@ it('case: with useMutation', async () => { it('case: with prefix', async () => { const prefixed = createPiniaColadaUtils(client, { prefix: '__prefix__' }) - expect(prefixed.nested.ping.key({ type: 'query' })).toEqual(['__prefix__', ['nested', 'ping'], { type: 'query' }]) - expect(prefixed.nested.ping.queryKey({ input: { input: 123 } })).not.toEqual(orpc.nested.ping.queryKey({ input: { input: 123 } })) + expect(prefixed.nested.ping.key({ type: 'query' })).toEqual([ + '__prefix__', + ['nested', 'ping'], + { type: 'query' }, + ]) + expect(prefixed.nested.ping.queryKey({ input: { input: 123 } })).not.toEqual( + orpc.nested.ping.queryKey({ input: { input: 123 } }), + ) - const mounted = mount(defineComponent({ - setup() { - const queryCache = useQueryCache() - const prefixedQuery = useQuery(prefixed.nested.ping.queryOptions({ input: { input: 123 } })) - const query = useQuery(orpc.nested.ping.queryOptions({ input: { input: 123 } })) + const mounted = mount( + defineComponent({ + setup() { + const queryCache = useQueryCache() + const prefixedQuery = useQuery(prefixed.nested.ping.queryOptions({ input: { input: 123 } })) + const query = useQuery(orpc.nested.ping.queryOptions({ input: { input: 123 } })) - return { prefixedQuery, query, queryCache } - }, - render: () => null, - })) + return { prefixedQuery, query, queryCache } + }, + render: () => null, + }), + ) await vi.waitFor(() => expect(mounted.vm.prefixedQuery.data.value).toEqual({ output: '123' })) await vi.waitFor(() => expect(mounted.vm.query.data.value).toEqual({ output: '123' })) @@ -271,13 +307,13 @@ it('case: with prefix', async () => { ).toEqual({ output: '123' }) // invalidating un-prefixed keys does not touch prefixed entries - mounted.vm.queryCache.invalidateQueries({ key: orpc.key() }) + void mounted.vm.queryCache.invalidateQueries({ key: orpc.key() }) expect(mounted.vm.prefixedQuery.isLoading.value).toEqual(false) expect(mounted.vm.query.isLoading.value).toEqual(true) // invalidating prefixed keys does not touch un-prefixed entries await vi.waitFor(() => expect(mounted.vm.query.isLoading.value).toEqual(false)) - mounted.vm.queryCache.invalidateQueries({ key: prefixed.key() }) + void mounted.vm.queryCache.invalidateQueries({ key: prefixed.key() }) expect(mounted.vm.prefixedQuery.isLoading.value).toEqual(true) expect(mounted.vm.query.isLoading.value).toEqual(false) }) @@ -291,7 +327,7 @@ it('case: with interceptors and plugins', async () => { plugins: [ { name: 'test-plugin', - init: options => ({ + init: (options) => ({ ...options, mutationInterceptors: [...(options.mutationInterceptors ?? []), mutationInterceptor], }), @@ -299,43 +335,49 @@ it('case: with interceptors and plugins', async () => { ], }) - const mounted = mount(defineComponent({ - setup() { - const query = useQuery(utils.nested.ping.queryOptions({ input: { input: 123 } })) - const mutation = useMutation(utils.nested.ping.mutationOptions()) + const mounted = mount( + defineComponent({ + setup() { + const query = useQuery(utils.nested.ping.queryOptions({ input: { input: 123 } })) + const mutation = useMutation(utils.nested.ping.mutationOptions()) - return { query, mutation } - }, - render: () => null, - })) + return { query, mutation } + }, + render: () => null, + }), + ) await vi.waitFor(() => expect(mounted.vm.query.data.value).toEqual({ output: '123' })) expect(queryInterceptor).toHaveBeenCalledTimes(1) - expect(queryInterceptor).toHaveBeenCalledWith(expect.objectContaining({ - path: ['nested', 'ping'], - input: { input: 123 }, - context: expect.objectContaining({ - [PINIA_COLADA_OPERATION_CONTEXT_SYMBOL]: { - key: utils.nested.ping.key({ type: 'query', input: { input: 123 } }), - type: 'query', - }, + expect(queryInterceptor).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['nested', 'ping'], + input: { input: 123 }, + context: expect.objectContaining({ + [PINIA_COLADA_OPERATION_CONTEXT_SYMBOL]: { + key: utils.nested.ping.key({ type: 'query', input: { input: 123 } }), + type: 'query', + }, + }), }), - })) + ) mounted.vm.mutation.mutate({ input: 456 }) await vi.waitFor(() => expect(mounted.vm.mutation.data.value).toEqual({ output: '456' })) expect(mutationInterceptor).toHaveBeenCalledTimes(1) - expect(mutationInterceptor).toHaveBeenCalledWith(expect.objectContaining({ - path: ['nested', 'ping'], - input: { input: 456 }, - context: expect.objectContaining({ - [PINIA_COLADA_OPERATION_CONTEXT_SYMBOL]: { - key: utils.nested.ping.key({ type: 'mutation', input: { input: 456 } }), - type: 'mutation', - }, + expect(mutationInterceptor).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['nested', 'ping'], + input: { input: 456 }, + context: expect.objectContaining({ + [PINIA_COLADA_OPERATION_CONTEXT_SYMBOL]: { + key: utils.nested.ping.key({ type: 'mutation', input: { input: 456 } }), + type: 'mutation', + }, + }), }), - })) + ) }) diff --git a/packages/pinia-colada/tsconfig.json b/packages/pinia-colada/tsconfig.json index 6a7efc5dc8..9979a0715c 100644 --- a/packages/pinia-colada/tsconfig.json +++ b/packages/pinia-colada/tsconfig.json @@ -1,18 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../contract" }, - { "path": "../openapi" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/*.bench.*", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/pino/README.md b/packages/pino/README.md index a66dc4c899..bc6c6322b7 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/pino/build.config.ts b/packages/pino/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/pino/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/pino/package.json b/packages/pino/package.json index 7066ea8171..cae728f82d 100644 --- a/packages/pino/package.json +++ b/packages/pino/package.json @@ -1,51 +1,47 @@ { "name": "@orpc/pino", - "type": "module", "version": "2.0.0-beta.48", "description": "Pino integration for oRPC: structured logging for typesafe APIs", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "logger", + "logging", + "observability", + "orpc", + "pino", + "typescript" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/pino" }, - "keywords": [ - "orpc", - "pino", - "logging", - "logger", - "observability", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "pino": ">=10.1.0" + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", @@ -55,5 +51,8 @@ }, "devDependencies": { "pino": "^10.4.0" + }, + "peerDependencies": { + "pino": ">=10.1.0" } } diff --git a/packages/pino/src/context.test.ts b/packages/pino/src/context.test.ts index 8e38124bc1..32e0684786 100644 --- a/packages/pino/src/context.test.ts +++ b/packages/pino/src/context.test.ts @@ -1,5 +1,6 @@ import pino from 'pino' -import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context' + +import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context.ts' it('getLogger', async () => { expect(getLogger({})).toBeUndefined() diff --git a/packages/pino/src/context.ts b/packages/pino/src/context.ts index b6b711fa30..64e712a4da 100644 --- a/packages/pino/src/context.ts +++ b/packages/pino/src/context.ts @@ -6,7 +6,9 @@ import type { Logger } from 'pino' * * @see {@link https://orpc.dev/docs/integrations/pino#providing-custom-logger-per-request | Pino Integration - Providing Custom Logger per Request} */ -export const LOGGER_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for('ORPC_PINO_LOGGER_CONTEXT') +export const LOGGER_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for( + 'ORPC_PINO_LOGGER_CONTEXT', +) /** * Context shape that carries the Pino logger injected by `PinoHandlerPlugin`. diff --git a/packages/pino/src/handler-plugin.test.ts b/packages/pino/src/handler-plugin.test.ts index c5e58a13cc..0c0d527720 100644 --- a/packages/pino/src/handler-plugin.test.ts +++ b/packages/pino/src/handler-plugin.test.ts @@ -1,10 +1,11 @@ -import type { StandardLazyRequest } from '@standard-server/core' import { ORPCError, os } from '@orpc/server' import { StandardHandler } from '@orpc/server/standard' import { AbortError } from '@orpc/shared' +import type { StandardLazyRequest } from '@standard-server/core' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context' -import { PinoHandlerPlugin } from './handler-plugin' + +import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context.ts' +import { PinoHandlerPlugin } from './handler-plugin.ts' const globalSpies = { child: vi.fn(), @@ -18,7 +19,10 @@ const globalSpies = { class FakeLogger { private _bindings: any - constructor(initial: any = {}, private readonly childDepth: number = 0) { + constructor( + initial: any = {}, + private readonly childDepth: number = 0, + ) { this._bindings = initial } @@ -103,9 +107,11 @@ describe('pinoHandlerPlugin', () => { it('logs request lifecycle for matched and unmatched requests', async () => { const baseLogger = new FakeLogger({ rpc: {} }) - const codec = createCodec(os.handler(() => { - throw new Error('boom') - })) + const codec = createCodec( + os.handler(() => { + throw new Error('boom') + }), + ) const handler = new StandardHandler(codec as any, { plugins: [new PinoHandlerPlugin({ logger: baseLogger as any, logLifecycle: true })], @@ -119,7 +125,7 @@ describe('pinoHandlerPlugin', () => { res: { status: 500, }, - responseTime: expect.toSatisfy(v => Number.isInteger(v) && v > 0), + responseTime: expect.toSatisfy((v) => Number.isInteger(v) && v > 0), }) vi.clearAllMocks() @@ -133,23 +139,31 @@ describe('pinoHandlerPlugin', () => { it('logs abort events with reason', async () => { const baseLogger = new FakeLogger({ rpc: {} }) const controller = new AbortController() - const codec = createCodec(os.handler(async () => { - await new Promise(resolve => setTimeout(resolve, 20)) - return 'pong' - })) + const codec = createCodec( + os.handler(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + return 'pong' + }), + ) const handler = new StandardHandler(codec as any, { plugins: [new PinoHandlerPlugin({ logger: baseLogger as any, logAbort: true })], }) - const promise = handler.handle(createRequest('GET', '/ping', controller.signal), { prefix: undefined, context: {} }) + const promise = handler.handle(createRequest('GET', '/ping', controller.signal), { + prefix: undefined, + context: {}, + }) setTimeout(() => controller.abort('manual'), 0) await promise expect(globalSpies.info).toHaveBeenCalledWith('request is aborted (manual)') - await handler.handle(createRequest('GET', '/ping', controller.signal), { prefix: undefined, context: {} }) + await handler.handle(createRequest('GET', '/ping', controller.signal), { + prefix: undefined, + context: {}, + }) expect(globalSpies.info).toHaveBeenCalledWith('request was aborted before handling (manual)') }) @@ -157,13 +171,21 @@ describe('pinoHandlerPlugin', () => { it('logs business errors (ORPCError) as warn except INTERNAL_SERVER_ERROR, unexpected errors as error, and abort errors as info', async () => { const baseLogger = new FakeLogger({ rpc: {} }) const businessError = new ORPCError('UNAUTHORIZED') - const handler1 = new StandardHandler(createCodec(os.handler(() => { - throw businessError - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], - }) + const handler1 = new StandardHandler( + createCodec( + os.handler(() => { + throw businessError + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], + }, + ) - const result1 = await handler1.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) + const result1 = await handler1.handle(createRequest('GET', '/ping'), { + prefix: undefined, + context: {}, + }) expect(result1.matched).toBe(true) expect(globalSpies.warn).toHaveBeenCalledWith(businessError) @@ -172,13 +194,21 @@ describe('pinoHandlerPlugin', () => { vi.clearAllMocks() const unexpectedError = new Error('boom') - const handler2 = new StandardHandler(createCodec(os.handler(() => { - throw unexpectedError - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], - }) + const handler2 = new StandardHandler( + createCodec( + os.handler(() => { + throw unexpectedError + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], + }, + ) - const result2 = await handler2.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) + const result2 = await handler2.handle(createRequest('GET', '/ping'), { + prefix: undefined, + context: {}, + }) expect(result2.matched).toBe(true) expect(result2.response?.status).toBe(500) @@ -188,13 +218,21 @@ describe('pinoHandlerPlugin', () => { vi.clearAllMocks() const abortError = new AbortError('reason') - const handler3 = new StandardHandler(createCodec(os.handler(() => { - throw abortError - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], - }) + const handler3 = new StandardHandler( + createCodec( + os.handler(() => { + throw abortError + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], + }, + ) - const result3 = await handler3.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) + const result3 = await handler3.handle(createRequest('GET', '/ping'), { + prefix: undefined, + context: {}, + }) expect(result3.matched).toBe(true) expect(globalSpies.info).toHaveBeenCalledWith(abortError) @@ -204,13 +242,21 @@ describe('pinoHandlerPlugin', () => { vi.clearAllMocks() const internalError = new ORPCError('INTERNAL_SERVER_ERROR') - const handler4 = new StandardHandler(createCodec(os.handler(() => { - throw internalError - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], - }) + const handler4 = new StandardHandler( + createCodec( + os.handler(() => { + throw internalError + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], + }, + ) - const result4 = await handler4.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) + const result4 = await handler4.handle(createRequest('GET', '/ping'), { + prefix: undefined, + context: {}, + }) expect(result4.matched).toBe(true) expect(globalSpies.error).toHaveBeenCalledWith(internalError) @@ -222,14 +268,19 @@ describe('pinoHandlerPlugin', () => { const businessError = new ORPCError('UNAUTHORIZED') const procedureErrorLevel = vi.fn( (error: unknown, level: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace') => - error instanceof ORPCError && error.code === 'UNAUTHORIZED' ? 'debug' as const : level, + error instanceof ORPCError && error.code === 'UNAUTHORIZED' ? ('debug' as const) : level, ) - const handler1 = new StandardHandler(createCodec(os.handler(() => { - throw businessError - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any, procedureErrorLevel })], - }) + const handler1 = new StandardHandler( + createCodec( + os.handler(() => { + throw businessError + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any, procedureErrorLevel })], + }, + ) await handler1.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) @@ -240,11 +291,16 @@ describe('pinoHandlerPlugin', () => { vi.clearAllMocks() const otherError = new ORPCError('CONFLICT') - const handler2 = new StandardHandler(createCodec(os.handler(() => { - throw otherError - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any, procedureErrorLevel })], - }) + const handler2 = new StandardHandler( + createCodec( + os.handler(() => { + throw otherError + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any, procedureErrorLevel })], + }, + ) await handler2.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) @@ -265,7 +321,9 @@ describe('pinoHandlerPlugin', () => { ], }) - await expect(handler.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} })).rejects.toThrow(error) + await expect( + handler.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }), + ).rejects.toThrow(error) expect(globalSpies.error).toHaveBeenCalledWith(error) }) @@ -273,14 +331,22 @@ describe('pinoHandlerPlugin', () => { const baseLogger = new FakeLogger({ rpc: {} }) let capturedBindings: any - const handler = new StandardHandler(createCodec(os.handler(({ context }) => { - capturedBindings = getLogger(context)?.bindings() - return 'pong' - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], - }) + const handler = new StandardHandler( + createCodec( + os.handler(({ context }) => { + capturedBindings = getLogger(context)?.bindings() + return 'pong' + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], + }, + ) - const result = await handler.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) + const result = await handler.handle(createRequest('GET', '/ping'), { + prefix: undefined, + context: {}, + }) expect(result.matched).toBe(true) expect(capturedBindings.rpc.method).toBe('ping') @@ -289,21 +355,28 @@ describe('pinoHandlerPlugin', () => { it('logs AsyncIteratorObject errors as error and aborted stream errors as info', async () => { const baseLogger = new FakeLogger({ rpc: {} }) const streamError = new Error('stream-error') - const handler1 = new StandardHandler(createCodec(os.handler(async function* () { - yield 1 - throw streamError - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], - }) + const handler1 = new StandardHandler( + createCodec( + os.handler(async function* () { + yield 1 + throw streamError + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], + }, + ) - const result1 = await handler1.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) + const result1 = await handler1.handle(createRequest('GET', '/ping'), { + prefix: undefined, + context: {}, + }) try { for await (const _ of result1.response?.body as AsyncIterable) { // consume only } - } - catch (error) { + } catch (error) { expect(error).toBe(streamError) } @@ -314,20 +387,27 @@ describe('pinoHandlerPlugin', () => { vi.clearAllMocks() const abortedStreamError = new AbortError('reason') - const handler2 = new StandardHandler(createCodec(os.handler(async function* () { - throw abortedStreamError - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], - }) + const handler2 = new StandardHandler( + createCodec( + os.handler(async function* () { + throw abortedStreamError + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], + }, + ) - const result2 = await handler2.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) + const result2 = await handler2.handle(createRequest('GET', '/ping'), { + prefix: undefined, + context: {}, + }) try { for await (const _ of result2.response?.body as AsyncIterable) { // consume only } - } - catch (error) { + } catch (error) { expect(error).toBe(abortedStreamError) } @@ -339,18 +419,26 @@ describe('pinoHandlerPlugin', () => { it('logs octet stream errors as error and aborted stream errors as info', async () => { const baseLogger = new FakeLogger({ rpc: {} }) const streamError = new Error('stream-error') - const handler1 = new StandardHandler(createCodec(os.handler(async () => { - return new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('chunk1')) - controller.error(streamError) - }, - }) - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], - }) + const handler1 = new StandardHandler( + createCodec( + os.handler(async () => { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('chunk1')) + controller.error(streamError) + }, + }) + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], + }, + ) - const result1 = await handler1.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) + const result1 = await handler1.handle(createRequest('GET', '/ping'), { + prefix: undefined, + context: {}, + }) try { const reader = (result1.response?.body as ReadableStream).getReader() @@ -360,8 +448,7 @@ describe('pinoHandlerPlugin', () => { break } } - } - catch (error) { + } catch (error) { expect(error).toBe(streamError) } @@ -372,17 +459,25 @@ describe('pinoHandlerPlugin', () => { vi.clearAllMocks() const abortedStreamError = new AbortError('reason') - const handler2 = new StandardHandler(createCodec(os.handler(async () => { - return new ReadableStream({ - start(controller) { - controller.error(abortedStreamError) - }, - }) - })) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], - }) + const handler2 = new StandardHandler( + createCodec( + os.handler(async () => { + return new ReadableStream({ + start(controller) { + controller.error(abortedStreamError) + }, + }) + }), + ) as any, + { + plugins: [new PinoHandlerPlugin({ logger: baseLogger as any })], + }, + ) - const result2 = await handler2.handle(createRequest('GET', '/ping'), { prefix: undefined, context: {} }) + const result2 = await handler2.handle(createRequest('GET', '/ping'), { + prefix: undefined, + context: {}, + }) try { const reader = (result2.response?.body as ReadableStream).getReader() while (true) { @@ -391,8 +486,7 @@ describe('pinoHandlerPlugin', () => { break } } - } - catch (error) { + } catch (error) { expect(error).toBe(abortedStreamError) } @@ -405,30 +499,39 @@ describe('pinoHandlerPlugin', () => { it('creates a child logger with id and req when none is provided in context', async () => { const baseLogger = new FakeLogger({ rpc: { existing: true } }) const handler = new StandardHandler(createCodec(os.handler(() => 'pong')) as any, { - plugins: [new PinoHandlerPlugin({ logger: baseLogger as any, generateRequestId: () => 'test-id' })], + plugins: [ + new PinoHandlerPlugin({ logger: baseLogger as any, generateRequestId: () => 'test-id' }), + ], }) const request = createRequest('POST', '/ping') await handler.handle(request, { prefix: undefined, context: {} }) expect(globalSpies.child).toHaveBeenCalledWith({}) - expect(globalSpies.setBindings).toHaveBeenCalledWith(expect.objectContaining({ - req: expect.objectContaining({ - id: 'test-id', - url: '/ping', - method: 'POST', - headers: expect.objectContaining({ - 'content-type': 'application/json', + expect(globalSpies.setBindings).toHaveBeenCalledWith( + expect.objectContaining({ + req: expect.objectContaining({ + id: 'test-id', + url: '/ping', + method: 'POST', + headers: expect.objectContaining({ + 'content-type': 'application/json', + }), }), }), - })) + ) expect(globalSpies.error).not.toHaveBeenCalled() }) it('preserves an existing req binding from the context logger', async () => { const loggerWithReq = new FakeLogger({ rpc: {}, req: { url: '/original', method: 'POST' } }) const handler = new StandardHandler(createCodec(os.handler(() => 'pong')) as any, { - plugins: [new PinoHandlerPlugin({ logger: loggerWithReq as any, generateRequestId: () => 'test-id' })], + plugins: [ + new PinoHandlerPlugin({ + logger: loggerWithReq as any, + generateRequestId: () => 'test-id', + }), + ], }) await handler.handle(createRequest('GET', '/ping'), { @@ -437,7 +540,7 @@ describe('pinoHandlerPlugin', () => { }) const setBindingsCalls = globalSpies.setBindings.mock.calls - expect(setBindingsCalls.some(call => call[0]?.req !== undefined)).toBe(false) + expect(setBindingsCalls.some((call) => call[0]?.req !== undefined)).toBe(false) }) it('uses the default pino logger when none is provided', () => { diff --git a/packages/pino/src/handler-plugin.ts b/packages/pino/src/handler-plugin.ts index faddbaf729..b48f8ed8bd 100644 --- a/packages/pino/src/handler-plugin.ts +++ b/packages/pino/src/handler-plugin.ts @@ -1,12 +1,26 @@ -import type { Context, ErrorMap, ProcedureClientInterceptor, Schema } from '@orpc/server' -import type { StandardHandlerInterceptor, StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions } from '@orpc/server/standard' -import type { Logger } from 'pino' -import type { LoggerContext } from './context' import { ORPCError, wrapAsyncIteratorPreservingEventMeta } from '@orpc/client' -import { isAbortError, isAsyncIteratorObject, ORPC_NAME, override, toArray, wrapReadableStream } from '@orpc/shared' +import type { Context, ErrorMap, ProcedureClientInterceptor, Schema } from '@orpc/server' +import type { + StandardHandlerInterceptor, + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, + StandardHandlerRoutingInterceptorOptions, +} from '@orpc/server/standard' +import { + isAbortError, + isAsyncIteratorObject, + ORPC_NAME, + override, + toArray, + wrapReadableStream, +} from '@orpc/shared' import { flattenStandardHeader } from '@standard-server/core' +import type { Logger } from 'pino' import pino from 'pino' -import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context' + +import type { LoggerContext } from './context.ts' +import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context.ts' export interface PinoHandlerPluginOptions { /** @@ -67,24 +81,33 @@ export class PinoHandlerPlugin implements StandardHandlerPlug before: string[] = ['~tracing', '~batch', '~hibernation'] private readonly logger: Exclude['logger'], undefined> - private readonly generateRequestId: Exclude['generateRequestId'], undefined> + private readonly generateRequestId: Exclude< + PinoHandlerPluginOptions['generateRequestId'], + undefined + > private readonly logLifecycle: Exclude['logLifecycle'], undefined> private readonly logAbort: Exclude['logAbort'], undefined> - private readonly procedureErrorLevel: Exclude['procedureErrorLevel'], undefined> + private readonly procedureErrorLevel: Exclude< + PinoHandlerPluginOptions['procedureErrorLevel'], + undefined + > - constructor( - options: PinoHandlerPluginOptions = {}, - ) { + constructor(options: PinoHandlerPluginOptions = {}) { this.logger = options.logger ?? pino() - this.generateRequestId = options.generateRequestId - ?? (({ request }) => flattenStandardHeader(request.headers['x-request-id']) ?? crypto.randomUUID()) + this.generateRequestId = + options.generateRequestId ?? + (({ request }) => + flattenStandardHeader(request.headers['x-request-id']) ?? crypto.randomUUID()) this.logLifecycle = options.logLifecycle ?? false this.logAbort = options.logAbort ?? false this.procedureErrorLevel = options.procedureErrorLevel ?? ((_, level) => level) } init(options: StandardHandlerOptions): StandardHandlerOptions { - const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ next, ...interceptorOptions }) => { + const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ + next, + ...interceptorOptions + }) => { const startMs = Date.now() const logger = ( @@ -132,15 +155,13 @@ export class PinoHandlerPlugin implements StandardHandlerPlug }, responseTime: Date.now() - startMs, }) - } - else { + } else { logger?.info('no matching procedure found') } } return result - } - catch (error) { + } catch (error) { /** * Any error here is internal (interceptor/framework), not business logic. * Indicates unexpected handler failure. @@ -159,24 +180,29 @@ export class PinoHandlerPlugin implements StandardHandlerPlug if (signal?.aborted) { logger?.info(`request was aborted before handling (${String(signal.reason)})`) - } - else { - signal?.addEventListener('abort', () => { - logger?.info(`request is aborted (${String(signal.reason)})`) - }, { once: true }) + } else { + signal?.addEventListener( + 'abort', + () => { + logger?.info(`request is aborted (${String(signal.reason)})`) + }, + { once: true }, + ) } } try { return await next() - } - catch (error) { + } catch (error) { logProcedureError(logger, error, this.procedureErrorLevel) throw error } } - const clientInterceptor: ProcedureClientInterceptor, ErrorMap> = async ({ next, context }) => { + const clientInterceptor: ProcedureClientInterceptor, ErrorMap> = async ({ + next, + context, + }) => { const output = await next() if (isAsyncIteratorObject(output)) { @@ -184,11 +210,14 @@ export class PinoHandlerPlugin implements StandardHandlerPlug * @remarks * **Warning**: Remember use `override` for AsyncIteratorObject to remain other special properties */ - return override(output, wrapAsyncIteratorPreservingEventMeta(output, { - onError: (error) => { - logProcedureError(getLogger(context), error, this.procedureErrorLevel) - }, - })) + return override( + output, + wrapAsyncIteratorPreservingEventMeta(output, { + onError: (error) => { + logProcedureError(getLogger(context), error, this.procedureErrorLevel) + }, + }), + ) } if (output instanceof ReadableStream) { @@ -196,11 +225,14 @@ export class PinoHandlerPlugin implements StandardHandlerPlug * @remarks * **Warning**: Remember use `override` for ReadableStream to remain other special properties */ - return override(output, wrapReadableStream(output, { - onError: (error) => { - logProcedureError(getLogger(context), error, this.procedureErrorLevel) - }, - })) + return override( + output, + wrapReadableStream(output, { + onError: (error) => { + logProcedureError(getLogger(context), error, this.procedureErrorLevel) + }, + }), + ) } return output @@ -208,18 +240,9 @@ export class PinoHandlerPlugin implements StandardHandlerPlug return { ...options, - routingInterceptors: [ - routingInterceptor, - ...toArray(options.routingInterceptors), - ], - interceptors: [ - interceptor, - ...toArray(options.interceptors), - ], - clientInterceptors: [ - clientInterceptor, - ...toArray(options.clientInterceptors), - ], + routingInterceptors: [routingInterceptor, ...toArray(options.routingInterceptors)], + interceptors: [interceptor, ...toArray(options.interceptors)], + clientInterceptors: [clientInterceptor, ...toArray(options.clientInterceptors)], } } } diff --git a/packages/pino/src/index.test.ts b/packages/pino/src/index.test.ts index 7e3b199e09..21296dce25 100644 --- a/packages/pino/src/index.test.ts +++ b/packages/pino/src/index.test.ts @@ -1,3 +1,3 @@ it('exports PinoHandlerPlugin', async () => { - expect(Object.keys(await import('./index'))).toContain('PinoHandlerPlugin') + expect(Object.keys(await import('./index.ts'))).toContain('PinoHandlerPlugin') }) diff --git a/packages/pino/src/index.ts b/packages/pino/src/index.ts index baf5a0d9be..b70e727a08 100644 --- a/packages/pino/src/index.ts +++ b/packages/pino/src/index.ts @@ -1,2 +1,2 @@ -export * from './context' -export * from './handler-plugin' +export * from './context.ts' +export * from './handler-plugin.ts' diff --git a/packages/pino/tsconfig.json b/packages/pino/tsconfig.json index 075f89b340..9979a0715c 100644 --- a/packages/pino/tsconfig.json +++ b/packages/pino/tsconfig.json @@ -1,17 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../server" }, - { "path": "../client" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/*.bench.*", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/publisher/README.md b/packages/publisher/README.md index 1089785da6..f77fd5c6de 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/publisher/build.config.ts b/packages/publisher/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/publisher/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/publisher/package.json b/packages/publisher/package.json index a8db8330b2..f9bd479901 100644 --- a/packages/publisher/package.json +++ b/packages/publisher/package.json @@ -1,75 +1,83 @@ { "name": "@orpc/publisher", - "type": "module", "version": "2.0.0-beta.48", "description": "Typesafe pub/sub for oRPC event streaming, with memory, Redis, and Upstash adapters", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "event-iterator", + "events", + "orpc", + "publish-subscribe", + "pubsub", + "redis", + "sse", + "typescript", + "upstash" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/publisher" }, - "keywords": [ - "orpc", - "pubsub", - "publish-subscribe", - "events", - "event-iterator", - "sse", - "redis", - "upstash", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./base-redis": "./src/adapters/base-redis.ts", + "./memory": "./src/adapters/memory.ts", + "./redis": "./src/adapters/redis.ts", + "./upstash": "./src/adapters/upstash.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./base-redis": { - "types": "./dist/adapters/base-redis.d.mts", - "import": "./dist/adapters/base-redis.mjs", - "default": "./dist/adapters/base-redis.mjs" + "types": "./dist/adapters/base-redis.d.ts", + "import": "./dist/adapters/base-redis.js", + "default": "./dist/adapters/base-redis.js" }, "./memory": { - "types": "./dist/adapters/memory.d.mts", - "import": "./dist/adapters/memory.mjs", - "default": "./dist/adapters/memory.mjs" + "types": "./dist/adapters/memory.d.ts", + "import": "./dist/adapters/memory.js", + "default": "./dist/adapters/memory.js" }, "./redis": { - "types": "./dist/adapters/redis.d.mts", - "import": "./dist/adapters/redis.mjs", - "default": "./dist/adapters/redis.mjs" + "types": "./dist/adapters/redis.d.ts", + "import": "./dist/adapters/redis.js", + "default": "./dist/adapters/redis.js" }, "./upstash": { - "types": "./dist/adapters/upstash.d.mts", - "import": "./dist/adapters/upstash.mjs", - "default": "./dist/adapters/upstash.mjs" + "types": "./dist/adapters/upstash.d.ts", + "import": "./dist/adapters/upstash.js", + "default": "./dist/adapters/upstash.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts", - "./base-redis": "./src/adapters/base-redis.ts", - "./memory": "./src/adapters/memory.ts", - "./redis": "./src/adapters/redis.ts", - "./upstash": "./src/adapters/upstash.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" + "prepack": "tsc" + }, + "dependencies": { + "@orpc/client": "workspace:*", + "@orpc/shared": "workspace:*", + "@standard-server/core": "~0.14.0" + }, + "devDependencies": { + "@upstash/redis": "^1.41.0", + "redis": "^6.3.1" }, "peerDependencies": { "@upstash/redis": ">=1.35.6", @@ -82,14 +90,5 @@ "redis": { "optional": true } - }, - "dependencies": { - "@orpc/client": "workspace:*", - "@orpc/shared": "workspace:*", - "@standard-server/core": "~0.14.0" - }, - "devDependencies": { - "@upstash/redis": "^1.41.0", - "redis": "^6.3.1" } } diff --git a/packages/publisher/src/adapters/base-redis.ts b/packages/publisher/src/adapters/base-redis.ts index d77b3d4c2e..a9b2e5e2fc 100644 --- a/packages/publisher/src/adapters/base-redis.ts +++ b/packages/publisher/src/adapters/base-redis.ts @@ -1,11 +1,16 @@ import type { RPCJsonSerialization } from '@orpc/client' -import type { Public, ThrowableError } from '@orpc/shared' -import type { EventMeta } from '@standard-server/core' -import type { PublisherOptions, PublisherResumeOptions, PublisherSubscribeListenerOptions } from '../publisher' import { RPCJsonSerializer } from '@orpc/client' +import type { Public, ThrowableError } from '@orpc/shared' import { once, parseEmptyableJSON, stringifyJSON } from '@orpc/shared' +import type { EventMeta } from '@standard-server/core' import { getEventMeta, unwrapEvent, withEventMeta } from '@standard-server/core' -import { Publisher } from '../publisher' + +import type { + PublisherOptions, + PublisherResumeOptions, + PublisherSubscribeListenerOptions, +} from '../publisher.ts' +import { Publisher } from '../publisher.ts' /** * Adds `ARGV[1]` to stream `KEYS[1]` and publishes it with its ID to the channel of the same name, @@ -192,10 +197,9 @@ export abstract class BaseRedisPublisher> exten const messageListener = (message: unknown) => { try { - const { id, data } = parseIfText(message) as { id?: string, data: SerializedEvent } + const { id, data } = parseIfText(message) as { id?: string; data: SerializedEvent } deduplicatingListener(this.deserializePayload(id, data) as T[K]) - } - catch (error) { + } catch (error) { onError?.(error as ThrowableError) } } @@ -214,8 +218,7 @@ export abstract class BaseRedisPublisher> exten const pending = pendingPayloads pendingPayloads = undefined pending.forEach(deduplicatingListener) - } - catch (error) { + } catch (error) { await unsubscribe() throw error } diff --git a/packages/publisher/src/adapters/memory.test.ts b/packages/publisher/src/adapters/memory.test.ts index c781505900..2be18657a9 100644 --- a/packages/publisher/src/adapters/memory.test.ts +++ b/packages/publisher/src/adapters/memory.test.ts @@ -1,5 +1,6 @@ import { getEventMeta, withEventMeta } from '@standard-server/core' -import { MemoryPublisher } from './memory' + +import { MemoryPublisher } from './memory.ts' type TestEvents = { message: { text: string } @@ -164,18 +165,22 @@ describe('memoryPublisher', () => { let activeRegistrations = unsubscribeFns.length unsubscribeFns.forEach((unsubscribe, index) => { - operations.push((async () => { - await delayMicrotasks(index * 2) - await publisher.publish('message', { text: `burst-${index}` }) - })()) + operations.push( + (async () => { + await delayMicrotasks(index * 2) + await publisher.publish('message', { text: `burst-${index}` }) + })(), + ) expectedCalls += activeRegistrations activeRegistrations -= 1 - operations.push((async () => { - await delayMicrotasks(index * 2 + 1) - await Promise.all([unsubscribe(), unsubscribe(), unsubscribe(), unsubscribe()]) - })()) + operations.push( + (async () => { + await delayMicrotasks(index * 2 + 1) + await Promise.all([unsubscribe(), unsubscribe(), unsubscribe(), unsubscribe()]) + })(), + ) }) await Promise.all(operations) @@ -262,6 +267,7 @@ describe('memoryPublisher', () => { it('delivers to every listener even when one unsubscribes itself while receiving', async () => { const publisher = new MemoryPublisher() + // oxlint-disable-next-line prefer-const -- assigned after the callback that reads it is created let unsubscribeFirst: () => Promise const first = vi.fn(() => { void unsubscribeFirst() @@ -291,9 +297,13 @@ describe('memoryPublisher', () => { const onError = vi.fn() const listener = vi.fn() - const unsubscribe1 = await publisher.subscribe('message', () => { - throw error - }, { onError }) + const unsubscribe1 = await publisher.subscribe( + 'message', + () => { + throw error + }, + { onError }, + ) const unsubscribe2 = await publisher.subscribe('message', listener) await expect(publisher.publish('message', { text: 'hello' })).resolves.toBeUndefined() diff --git a/packages/publisher/src/adapters/memory.ts b/packages/publisher/src/adapters/memory.ts index 574c2a8d01..3d1ee1496f 100644 --- a/packages/publisher/src/adapters/memory.ts +++ b/packages/publisher/src/adapters/memory.ts @@ -1,8 +1,13 @@ import type { ThrowableError } from '@orpc/shared' -import type { PublisherOptions, PublisherResumeOptions, PublisherSubscribeListenerOptions } from '../publisher' import { compareSequentialIds, once, SequentialIdGenerator } from '@orpc/shared' import { getEventMeta, unwrapEvent, withEventMeta } from '@standard-server/core' -import { Publisher } from '../publisher' + +import type { + PublisherOptions, + PublisherResumeOptions, + PublisherSubscribeListenerOptions, +} from '../publisher.ts' +import { Publisher } from '../publisher.ts' export interface MemoryPublisherOptions extends PublisherOptions { /** @@ -48,7 +53,7 @@ export class MemoryPublisher> extends Publisher let bucket = this.eventsMap.get(event) if (!bucket) { - this.eventsMap.set(event, bucket = []) + this.eventsMap.set(event, (bucket = [])) } const [original, meta] = unwrapEvent(payload) @@ -58,7 +63,7 @@ export class MemoryPublisher> extends Publisher bucket.push({ expiresAt, payload }) } - this.listenersMap.get(event)?.forEach(listener => listener(payload)) + this.listenersMap.get(event)?.forEach((listener) => listener(payload)) } protected async subscribeListener( @@ -81,15 +86,14 @@ export class MemoryPublisher> extends Publisher const isolatedListener = (payload: T[K]) => { try { listener(payload) - } - catch (error) { + } catch (error) { options?.onError?.(error as ThrowableError) } } let listeners = this.listenersMap.get(event) if (!listeners) { - this.listenersMap.set(event, listeners = []) + this.listenersMap.set(event, (listeners = [])) } listeners.push(isolatedListener) @@ -102,8 +106,7 @@ export class MemoryPublisher> extends Publisher if (remaining.length === 0) { this.listenersMap.delete(event) - } - else { + } else { this.listenersMap.set(event, remaining) } }) @@ -134,10 +137,7 @@ export class MemoryPublisher> extends Publisher } } -function findResumeStartIndex( - bucket: Array>, - lastEventId: string, -): number { +function findResumeStartIndex(bucket: Array>, lastEventId: string): number { let lo = 0 let hi = bucket.length @@ -147,8 +147,7 @@ function findResumeStartIndex( if (id !== undefined && compareSequentialIds(id, lastEventId) > 0) { hi = mid - } - else { + } else { lo = mid + 1 } } @@ -156,10 +155,7 @@ function findResumeStartIndex( return lo } -function findFirstUnexpiredIndex( - bucket: Array>, - now: number, -): number { +function findFirstUnexpiredIndex(bucket: Array>, now: number): number { let lo = 0 let hi = bucket.length @@ -168,8 +164,7 @@ function findFirstUnexpiredIndex( if (bucket[mid]!.expiresAt <= now) { lo = mid + 1 - } - else { + } else { hi = mid } } diff --git a/packages/publisher/src/adapters/redis.test.ts b/packages/publisher/src/adapters/redis.test.ts index d863bbacc0..bf030b86f0 100644 --- a/packages/publisher/src/adapters/redis.test.ts +++ b/packages/publisher/src/adapters/redis.test.ts @@ -1,11 +1,13 @@ -import type { RedisClientType } from 'redis' -import type { RedisPublisherOptions } from './redis' import { Buffer } from 'node:buffer' + import { RPCJsonSerializer } from '@orpc/client' import { getOrBind, promiseWithResolvers, sleep } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@standard-server/core' +import type { RedisClientType } from 'redis' import { createClient } from 'redis' -import { RedisPublisher } from './redis' + +import type { RedisPublisherOptions } from './redis.ts' +import { RedisPublisher } from './redis.ts' const REDIS_URL = process.env.REDIS_URL @@ -30,13 +32,10 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () options: RedisPublisherOptions = {}, { useRedis = redis }: { useRedis?: RedisClientType } = {}, ) { - const publisher = new RedisPublisher>( - useRedis, - { - prefix: `redis:${crypto.randomUUID()}:`, - ...options, - }, - ) + const publisher = new RedisPublisher>(useRedis, { + prefix: `redis:${crypto.randomUUID()}:`, + ...options, + }) ;(publisher as any).xTrimExactness = '=' // for easier testing @@ -55,30 +54,41 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () await publisher.publish(ignoredEvent, { order: 99 }) await publisher.publish(liveEvent, { order: 1 }) - await publisher.publish(liveEvent, withEventMeta({ order: 2 }, { id: '__id__', comments: ['__comment__'] })) + await publisher.publish( + liveEvent, + withEventMeta({ order: 2 }, { id: '__id__', comments: ['__comment__'] }), + ) await vi.waitFor(() => { expect(listener).toHaveBeenCalledTimes(2) }) - expect(listener).toHaveBeenNthCalledWith(1, expect.toSatisfy((payload) => { - expect(payload).toEqual({ order: 1 }) - expect(getEventMeta(payload)).toEqual(undefined) + expect(listener).toHaveBeenNthCalledWith( + 1, + expect.toSatisfy((payload) => { + expect(payload).toEqual({ order: 1 }) + expect(getEventMeta(payload)).toEqual(undefined) - return true - })) + return true + }), + ) - expect(listener).toHaveBeenNthCalledWith(2, expect.toSatisfy((payload) => { - expect(payload).toEqual({ order: 2 }) - expect(getEventMeta(payload)).toEqual({ id: '__id__', comments: ['__comment__'] }) + expect(listener).toHaveBeenNthCalledWith( + 2, + expect.toSatisfy((payload) => { + expect(payload).toEqual({ order: 2 }) + expect(getEventMeta(payload)).toEqual({ id: '__id__', comments: ['__comment__'] }) - return true - })) + return true + }), + ) await unsubscribe() const resumeAttempt = vi.fn() - const unsubscribeResumeAttempt = await publisher.subscribe(liveEvent, resumeAttempt, { lastEventId: '0' }) + const unsubscribeResumeAttempt = await publisher.subscribe(liveEvent, resumeAttempt, { + lastEventId: '0', + }) await sleep(300) @@ -194,7 +204,9 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () await unsubscribe() }) - it('deduplicates events that race between resume and live delivery during reconnect', async ({ onTestFinished }) => { + it('deduplicates events that race between resume and live delivery during reconnect', async ({ + onTestFinished, + }) => { const { resolve, promise } = promiseWithResolvers() const delayedRedis = new Proxy(createClient({ url: REDIS_URL }), { get(target, p) { @@ -214,9 +226,12 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () delayedRedis.destroy() }) - const publisher = createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - }, { useRedis: delayedRedis }) + const publisher = createTestingPublisher( + { + resume: { enabled: true, ttl: 10_000 }, + }, + { useRedis: delayedRedis }, + ) const event = 'timeline' await publisher.publish(event, { order: 1 }) @@ -235,54 +250,76 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () expect(listener).toHaveBeenCalledTimes(4) }) - expect(listener.mock.calls.map(call => call[0].order)).toEqual([1, 2, 3, 4]) + expect(listener.mock.calls.map((call) => call[0].order)).toEqual([1, 2, 3, 4]) await unsubscribe() }) - it('keeps live delivery in stream order under concurrent publishers so resume skips nothing', async ({ onTestFinished }) => { + it('keeps live delivery in stream order under concurrent publishers so resume skips nothing', async ({ + onTestFinished, + }) => { const prefix = `concurrent:${crypto.randomUUID()}:` const event = 'orders' // Separate connections, like publishers in different processes. const clients = Array.from({ length: 4 }, () => createClient({ url: REDIS_URL })) onTestFinished(() => { - clients.forEach(client => client.destroy()) + clients.forEach((client) => client.destroy()) }) - const publishers = clients.map(client => createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - prefix, - }, { useRedis: client })) + const publishers = clients.map((client) => + createTestingPublisher( + { + resume: { enabled: true, ttl: 10_000 }, + prefix, + }, + { useRedis: client }, + ), + ) const listener = vi.fn() const unsubscribe = await publishers[0]!.subscribe(event, listener) - await Promise.all(Array.from({ length: 500 }, (_, order) => publishers[order % publishers.length]!.publish(event, { order }))) + await Promise.all( + Array.from({ length: 500 }, (_, order) => + publishers[order % publishers.length]!.publish(event, { order }), + ), + ) await vi.waitFor(() => { expect(listener).toHaveBeenCalledTimes(500) }) - const streamIds = (await redis.xRange(`${prefix}${event}`, '-', '+'))!.map(entry => entry.id) + const streamIds = (await redis.xRange(`${prefix}${event}`, '-', '+'))!.map((entry) => entry.id) expect(listener.mock.calls.map(([payload]) => getEventMeta(payload)?.id)).toEqual(streamIds) await unsubscribe() }) - it('delivers live and resumed events when the client has a keyPrefix', async ({ onTestFinished }) => { + it('delivers live and resumed events when the client has a keyPrefix', async ({ + onTestFinished, + }) => { // node-redis prefixes keys but not channels, so the adapter prefixes channels to match. - const prefixedRedis = createClient({ url: REDIS_URL, keyPrefix: `key-prefix:${crypto.randomUUID()}:` }) + const prefixedRedis = createClient({ + url: REDIS_URL, + keyPrefix: `key-prefix:${crypto.randomUUID()}:`, + }) const subscriber = prefixedRedis.duplicate() onTestFinished(() => { prefixedRedis.destroy() subscriber.destroy() }) const prefix = `prefix:${crypto.randomUUID()}:` - const publisher = createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - subscriber, - prefix, - }, { useRedis: prefixedRedis }) - const publisherWithoutResume = createTestingPublisher({ subscriber, prefix }, { useRedis: prefixedRedis }) + const publisher = createTestingPublisher( + { + resume: { enabled: true, ttl: 10_000 }, + subscriber, + prefix, + }, + { useRedis: prefixedRedis }, + ) + const publisherWithoutResume = createTestingPublisher( + { subscriber, prefix }, + { useRedis: prefixedRedis }, + ) const event = 'orders' const listener = vi.fn() @@ -310,8 +347,9 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () }) it('rejects a client whose keyPrefix is not a string', () => { - expect(() => new RedisPublisher(createClient({ keyPrefix: Buffer.from('app:') }))) - .toThrow('RedisPublisher only supports a string keyPrefix on the Redis client.') + expect(() => new RedisPublisher(createClient({ keyPrefix: Buffer.from('app:') }))).toThrow( + 'RedisPublisher only supports a string keyPrefix on the Redis client.', + ) }) it('trims stale resume history on the next publish and lets Redis expire the stream key', async () => { @@ -352,15 +390,14 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () constructor( public name: string, public date: Date, - ) { - } + ) {} } const serializer = new RPCJsonSerializer({ handlers: { person: { - condition: p => p instanceof Person, - serialize: p => ({ name: p.name, date: p.date }), + condition: (p) => p instanceof Person, + serialize: (p) => ({ name: p.name, date: p.date }), deserialize: (p: any) => new Person(p.name, p.date), }, }, @@ -415,10 +452,12 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () const listener = vi.fn() const onError = vi.fn() - await expect(publisher.subscribe(event, listener, { - lastEventId: 'invalid-id-format', - onError, - })).rejects.toThrow() + await expect( + publisher.subscribe(event, listener, { + lastEventId: 'invalid-id-format', + onError, + }), + ).rejects.toThrow() await publisher.publish(event, { order: 1 }) await sleep(300) @@ -488,7 +527,9 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () await unsubscribe2() }) - it('reports subscriber connection errors to the onError of active subscriptions only', async ({ onTestFinished }) => { + it('reports subscriber connection errors to the onError of active subscriptions only', async ({ + onTestFinished, + }) => { const userSubscriber = redis.duplicate() onTestFinished(() => { userSubscriber.destroy() @@ -517,7 +558,9 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () } }) - it('reports a dropped subscriber connection and keeps delivering after node-redis reconnects', async ({ onTestFinished }) => { + it('reports a dropped subscriber connection and keeps delivering after node-redis reconnects', async ({ + onTestFinished, + }) => { // `duplicate()` copies the name, which identifies the subscriber connection in `CLIENT LIST`. const name = `dropped:${crypto.randomUUID()}` const namedRedis = createClient({ url: REDIS_URL, name }) @@ -535,16 +578,19 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () const unsubscribe = await publisher.subscribe(event, listener, { onError }) // Kill only this connection, since `CLIENT KILL TYPE pubsub` would break concurrent tests. - const { id } = (await redis.clientList()).find(client => client.name === name)! + const { id } = (await redis.clientList()).find((client) => client.name === name)! await redis.clientKill({ filter: 'ID', id }) await vi.waitFor(() => { expect(onError).toHaveBeenCalledWith(expect.any(Error)) }) - await vi.waitFor(async () => { - expect(await redis.pubSubNumSub(channel)).toEqual({ [channel]: 1 }) - }, { timeout: 5000 }) + await vi.waitFor( + async () => { + expect(await redis.pubSubNumSub(channel)).toEqual({ [channel]: 1 }) + }, + { timeout: 5000 }, + ) await publisher.publish(event, { order: 1 }) @@ -563,8 +609,14 @@ describe.concurrent('redisPublisher', { skip: !REDIS_URL, timeout: 20_000 }, () const redis3 = createClient({ url: REDIS_URL }) const prefix = `lazy:${crypto.randomUUID()}:` - const publisher = createTestingPublisher({ prefix, resume: { enabled: true } }, { useRedis: redis1 }) - const subscriber = createTestingPublisher({ prefix, resume: { enabled: true }, subscriber: redis3 }, { useRedis: redis2 }) + const publisher = createTestingPublisher( + { prefix, resume: { enabled: true } }, + { useRedis: redis1 }, + ) + const subscriber = createTestingPublisher( + { prefix, resume: { enabled: true }, subscriber: redis3 }, + { useRedis: redis2 }, + ) expect(redis1.isOpen).toBe(false) await Promise.all([ diff --git a/packages/publisher/src/adapters/redis.ts b/packages/publisher/src/adapters/redis.ts index b0be2f6dd8..b8e6fe5fee 100644 --- a/packages/publisher/src/adapters/redis.ts +++ b/packages/publisher/src/adapters/redis.ts @@ -1,7 +1,8 @@ import type { ThrowableError } from '@orpc/shared' import type { RedisClientType } from 'redis' -import type { BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis' -import { BaseRedisPublisher } from './base-redis' + +import type { BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis.ts' +import { BaseRedisPublisher } from './base-redis.ts' export interface RedisPublisherOptions extends BaseRedisPublisherOptions { /** @@ -18,7 +19,10 @@ export interface RedisPublisherOptions extends BaseRedisPublisherOptions { * The `onError` callbacks of the subscriptions on each subscriber, so publishers sharing * a subscriber, such as those created by `prefix`, add a single `error` listener to it. */ -const subscriberOnErrors = /* @__PURE__ */ new WeakMap void>>() +const subscriberOnErrors = /* @__PURE__ */ new WeakMap< + object, + Array<(error: ThrowableError) => void> +>() /** * Publisher adapter for Redis. Distributes events across processes via @@ -58,7 +62,7 @@ export class RedisPublisher> extends BaseRedisP this.subscriber.on('error', (error) => { // Snapshot, since a callback may unsubscribe itself mid-dispatch. - onErrors.slice().forEach(onError => onError(error)) + onErrors.slice().forEach((onError) => onError(error)) }) } @@ -71,7 +75,11 @@ export class RedisPublisher> extends BaseRedisP * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} */ prefix(prefix: string): RedisPublisher { - return new RedisPublisher(this.redis, { ...this.options, subscriber: this.subscriber, prefix: `${this.eventPrefix}${prefix}` }) + return new RedisPublisher(this.redis, { + ...this.options, + subscriber: this.subscriber, + prefix: `${this.eventPrefix}${prefix}`, + }) } protected async publishMessage(channel: string, message: string): Promise { @@ -118,7 +126,8 @@ export class RedisPublisher> extends BaseRedisP const results = await this.redis.xRead({ key, id: lastId }) - const entries: Array<{ id: string, message: Record }> = results?.[0]?.messages ?? [] + const entries: Array<{ id: string; message: Record }> = + results?.[0]?.messages ?? [] return entries.map(({ id, message }) => ({ id, data: message.data! })) } diff --git a/packages/publisher/src/adapters/upstash.test.ts b/packages/publisher/src/adapters/upstash.test.ts index f9985b6538..771de68feb 100644 --- a/packages/publisher/src/adapters/upstash.test.ts +++ b/packages/publisher/src/adapters/upstash.test.ts @@ -1,9 +1,10 @@ -import type { UpstashPublisherOptions } from './upstash' import { RPCJsonSerializer } from '@orpc/client' import { getOrBind, promiseWithResolvers, sleep } from '@orpc/shared' import { getEventMeta, withEventMeta } from '@standard-server/core' import { Redis } from '@upstash/redis' -import { UpstashPublisher } from './upstash' + +import type { UpstashPublisherOptions } from './upstash.ts' +import { UpstashPublisher } from './upstash.ts' const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN @@ -16,7 +17,10 @@ describe.concurrent( 'upstashPublisher', { // TODO: Upstash is not compatible with Node 26 yet — temporarily disable these tests and revisit in the future. - skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN || process.versions.node.startsWith('26.'), + skip: + !UPSTASH_REDIS_REST_URL || + !UPSTASH_REDIS_REST_TOKEN || + process.versions.node.startsWith('26.'), timeout: 20000, }, () => { @@ -31,13 +35,10 @@ describe.concurrent( useRedis?: Redis } = {}, ) { - const publisher = new UpstashPublisher>( - useRedis, - { - prefix: `upstash:${crypto.randomUUID()}:`, - ...options, - }, - ) + const publisher = new UpstashPublisher>(useRedis, { + prefix: `upstash:${crypto.randomUUID()}:`, + ...options, + }) ;(publisher as any).xTrimExactness = '=' // for easier testing @@ -72,30 +73,41 @@ describe.concurrent( await publisher.publish(ignoredEvent, { order: 99 }) await publisher.publish(liveEvent, { order: 1 }) - await publisher.publish(liveEvent, withEventMeta({ order: 2 }, { id: '__id__', comments: ['__comment__'] })) + await publisher.publish( + liveEvent, + withEventMeta({ order: 2 }, { id: '__id__', comments: ['__comment__'] }), + ) await vi.waitFor(() => { expect(listener).toHaveBeenCalledTimes(2) }) - expect(listener).toHaveBeenNthCalledWith(1, expect.toSatisfy((payload) => { - expect(payload).toEqual({ order: 1 }) - expect(getEventMeta(payload)).toEqual(undefined) + expect(listener).toHaveBeenNthCalledWith( + 1, + expect.toSatisfy((payload) => { + expect(payload).toEqual({ order: 1 }) + expect(getEventMeta(payload)).toEqual(undefined) - return true - })) + return true + }), + ) - expect(listener).toHaveBeenNthCalledWith(2, expect.toSatisfy((payload) => { - expect(payload).toEqual({ order: 2 }) - expect(getEventMeta(payload)).toEqual({ id: '__id__', comments: ['__comment__'] }) + expect(listener).toHaveBeenNthCalledWith( + 2, + expect.toSatisfy((payload) => { + expect(payload).toEqual({ order: 2 }) + expect(getEventMeta(payload)).toEqual({ id: '__id__', comments: ['__comment__'] }) - return true - })) + return true + }), + ) await unsubscribe() const resumeAttempt = vi.fn() - const unsubscribeResumeAttempt = await publisher.subscribe(liveEvent, resumeAttempt, { lastEventId: '0' }) + const unsubscribeResumeAttempt = await publisher.subscribe(liveEvent, resumeAttempt, { + lastEventId: '0', + }) await sleep(300) @@ -108,13 +120,16 @@ describe.concurrent( const publisher = createTestingPublisher({ resume: { enabled: true, ttl: 10_000 }, }) - const event = ('orders') + const event = 'orders' const liveListener = vi.fn() const unsubscribeLive = await publisher.subscribe(event, liveListener) const firstPayload = { order: 1 } - const secondPayload = withEventMeta({ order: 2 }, { id: 'client-id', comments: ['audit-log'] }) + const secondPayload = withEventMeta( + { order: 2 }, + { id: 'client-id', comments: ['audit-log'] }, + ) const thirdPayload = { order: 3 } await publisher.publish(event, firstPayload) @@ -161,7 +176,7 @@ describe.concurrent( const publisher = createTestingPublisher({ resume: { enabled: true, ttl: 10_000 }, }) - const event = ('empty-backlog') + const event = 'empty-backlog' const listener = vi.fn() const unsubscribe = await publisher.subscribe(event, listener, { lastEventId: '0' }) @@ -203,10 +218,13 @@ describe.concurrent( }, ) - const publisher = createTestingPublisher({ - resume: { enabled: true, ttl: 10_000 }, - }, { useRedis: redis }) - const event = ('timeline') + const publisher = createTestingPublisher( + { + resume: { enabled: true, ttl: 10_000 }, + }, + { useRedis: redis }, + ) + const event = 'timeline' await publisher.publish(event, { order: 1 }) await publisher.publish(event, { order: 2 }) @@ -224,7 +242,7 @@ describe.concurrent( expect(listener).toHaveBeenCalledTimes(4) }) - expect(listener.mock.calls.map(call => call[0].order)).toEqual([1, 2, 3, 4]) + expect(listener.mock.calls.map((call) => call[0].order)).toEqual([1, 2, 3, 4]) await unsubscribe() }) @@ -236,7 +254,7 @@ describe.concurrent( }) const subscribeSpy = vi.spyOn(dedicatedRedis, 'subscribe') const publisher = createTestingPublisher({}, { useRedis: dedicatedRedis }) - const event = ('shared-subscription') + const event = 'shared-subscription' const listener1 = vi.fn() const listener2 = vi.fn() @@ -277,13 +295,17 @@ describe.concurrent( const unsubscribe = await publisher.subscribe(event, listener) // Each command is a separate HTTP request, like publishers in different processes. - await Promise.all(Array.from({ length: 50 }, (_, order) => publisher.publish(event, { order }))) + await Promise.all( + Array.from({ length: 50 }, (_, order) => publisher.publish(event, { order })), + ) await vi.waitFor(() => { expect(listener).toHaveBeenCalledTimes(50) }) - const streamIds = (await redis.xread(`${prefix}${event}`, '0') as any)[0][1].map(([id]: [string]) => id) + const streamIds = ((await redis.xread(`${prefix}${event}`, '0')) as any)[0][1].map( + ([id]: [string]) => id, + ) expect(listener.mock.calls.map(([payload]) => getEventMeta(payload)?.id)).toEqual(streamIds) await unsubscribe() @@ -304,13 +326,13 @@ describe.concurrent( publisher.publish(event, { order: 3 }), ]) - const beforeCleanup = await redis.xread(key, '0') as any + const beforeCleanup = (await redis.xread(key, '0')) as any expect(beforeCleanup[0][1].length).toBe(3) await sleep(1100) await publisher.publish(event, { order: 4 }) - const afterCleanup = await redis.xread(key, '0') as any + const afterCleanup = (await redis.xread(key, '0')) as any expect(afterCleanup[0][1].length).toBe(1) const ttl = await redis.ttl(key) @@ -327,14 +349,14 @@ describe.concurrent( constructor( public name: string, public date: Date, - ) { } + ) {} } const serializer = new RPCJsonSerializer({ handlers: { person: { - condition: p => p instanceof Person, - serialize: p => ({ name: p.name, date: p.date }), + condition: (p) => p instanceof Person, + serialize: (p) => ({ name: p.name, date: p.date }), deserialize: (p: any) => new Person(p.name, p.date), }, }, @@ -382,14 +404,16 @@ describe.concurrent( const publisher = createTestingPublisher({ resume: { enabled: true, ttl: 10_000 }, }) - const event = ('resume-errors') + const event = 'resume-errors' const listener = vi.fn() const onError = vi.fn() - await expect(publisher.subscribe(event, listener, { - lastEventId: 'invalid-id-format', - onError, - })).rejects.toThrow() + await expect( + publisher.subscribe(event, listener, { + lastEventId: 'invalid-id-format', + onError, + }), + ).rejects.toThrow() await publisher.publish(event, { order: 1 }) @@ -467,8 +491,8 @@ describe.concurrent( retry: false, }) const publisher = createTestingPublisher({}, { useRedis: invalidRedis }) - const event1 = ('connection-error') - const event2 = ('connection-error') + const event1 = 'connection-error' + const event2 = 'connection-error' const listener1 = vi.fn() const listener2 = vi.fn() const onError1 = vi.fn() @@ -488,7 +512,7 @@ describe.concurrent( it('does not crash if teardown runs after internal maps were already pruned', async () => { const publisher = createTestingPublisher() - const event = ('defensive-cleanup') + const event = 'defensive-cleanup' const listener = vi.fn() const onError = vi.fn() @@ -508,13 +532,16 @@ describe.concurrent( }) it('works with clients that disable automatic deserialization', async () => { - const publisher = createTestingPublisher({ resume: { enabled: true, ttl: 10_000 } }, { - useRedis: new Redis({ - url: UPSTASH_REDIS_REST_URL, - token: UPSTASH_REDIS_REST_TOKEN, - automaticDeserialization: false, - }), - }) + const publisher = createTestingPublisher( + { resume: { enabled: true, ttl: 10_000 } }, + { + useRedis: new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + automaticDeserialization: false, + }), + }, + ) const event = 'orders' const listener = vi.fn() @@ -526,7 +553,10 @@ describe.concurrent( }) expect(listener.mock.calls[0]![0]).toEqual({ order: 1 }) - expect(getEventMeta(listener.mock.calls[0]![0])).toEqual({ id: expect.any(String), comments: ['audit'] }) + expect(getEventMeta(listener.mock.calls[0]![0])).toEqual({ + id: expect.any(String), + comments: ['audit'], + }) await unsubscribe() @@ -545,6 +575,7 @@ describe.concurrent( it('delivers to every listener even when one unsubscribes itself while receiving', async () => { const publisher = createTestingPublisher() const event = 'self-unsubscribe' + // oxlint-disable-next-line prefer-const -- assigned after the callback that reads it is created let unsubscribeFirst: () => Promise const first = vi.fn(() => { void unsubscribeFirst() @@ -597,7 +628,7 @@ describe.concurrent( publisher.subscribe(event, listener3), ]) - expect(results.map(result => result.status)).toEqual(['rejected', 'fulfilled', 'fulfilled']) + expect(results.map((result) => result.status)).toEqual(['rejected', 'fulfilled', 'fulfilled']) expect(subscribes).toBe(1) await publisher.publish(event, { order: 1 }) @@ -616,12 +647,15 @@ describe.concurrent( it('fan-outs subscription errors to every registered onError handler', async () => { const subscription = createFakeSubscription() - const publisher = createTestingPublisher({}, { - useRedis: withFakeSubscribe(redis, () => { - queueMicrotask(() => subscription.emit('subscribe', 1)) - return subscription - }), - }) + const publisher = createTestingPublisher( + {}, + { + useRedis: withFakeSubscribe(redis, () => { + queueMicrotask(() => subscription.emit('subscribe', 1)) + return subscription + }), + }, + ) const event = 'connection-lost' const onError1 = vi.fn() const onError2 = vi.fn() @@ -643,20 +677,28 @@ describe.concurrent( it('tears down a subscription that fails before it is established', async () => { const subscription = createFakeSubscription() - const publisher = createTestingPublisher({}, { - useRedis: withFakeSubscribe(redis, () => { - queueMicrotask(() => subscription.emit('error', new Error('connection refused'))) - return subscription - }), - }) + const publisher = createTestingPublisher( + {}, + { + useRedis: withFakeSubscribe(redis, () => { + queueMicrotask(() => subscription.emit('error', new Error('connection refused'))) + return subscription + }), + }, + ) - await expect(publisher.subscribe('unreachable', vi.fn())).rejects.toThrow('connection refused') + await expect(publisher.subscribe('unreachable', vi.fn())).rejects.toThrow( + 'connection refused', + ) expect(subscription.unsubscribe).toHaveBeenCalledTimes(1) }) it('opens a fresh subscription when subscribing while the previous one is being torn down', async () => { - const dedicatedRedis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }) + const dedicatedRedis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + }) const subscribeSpy = vi.spyOn(dedicatedRedis, 'subscribe') const publisher = createTestingPublisher({}, { useRedis: dedicatedRedis }) const event = 'resubscribe' @@ -664,7 +706,10 @@ describe.concurrent( const second = vi.fn() const unsubscribeFirst = await publisher.subscribe(event, first) - const [, unsubscribeSecond] = await Promise.all([unsubscribeFirst(), publisher.subscribe(event, second)]) + const [, unsubscribeSecond] = await Promise.all([ + unsubscribeFirst(), + publisher.subscribe(event, second), + ]) await publisher.publish(event, { order: 1 }) await vi.waitFor(() => { @@ -684,9 +729,10 @@ function createFakeSubscription() { return { on: (type: string, handler: (event: unknown) => void) => { - handlers.set(type, [...handlers.get(type) ?? [], handler]) + handlers.set(type, [...(handlers.get(type) ?? []), handler]) }, - emit: (type: string, event: unknown) => handlers.get(type)?.forEach(handler => handler(event)), + emit: (type: string, event: unknown) => + handlers.get(type)?.forEach((handler) => handler(event)), unsubscribe: vi.fn(async () => {}), } } @@ -721,13 +767,17 @@ it('does not broadcast a throwing onError to other subscribers', async () => { const listener2 = vi.fn() const onError2 = vi.fn() - const unsubscribe1 = await publisher.subscribe('room', () => { - throw new Error('listener failed') - }, { - onError: () => { - throw new Error('onError failed') + const unsubscribe1 = await publisher.subscribe( + 'room', + () => { + throw new Error('listener failed') }, - }) + { + onError: () => { + throw new Error('onError failed') + }, + }, + ) const unsubscribe2 = await publisher.subscribe('room', listener2, { onError: onError2 }) await publisher.publish('room', { order: 1 }) @@ -735,8 +785,7 @@ it('does not broadcast a throwing onError to other subscribers', async () => { // Like Upstash, which emits a throw from a message handler as an `error` event. try { subscription.emit('message', { channel: 'room', message: messages[0] }) - } - catch (error) { + } catch (error) { subscription.emit('error', error) } diff --git a/packages/publisher/src/adapters/upstash.ts b/packages/publisher/src/adapters/upstash.ts index 438adccb13..85b339c10d 100644 --- a/packages/publisher/src/adapters/upstash.ts +++ b/packages/publisher/src/adapters/upstash.ts @@ -1,8 +1,9 @@ import type { ThrowableError } from '@orpc/shared' -import type { Redis } from '@upstash/redis' -import type { BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis' import { promiseWithResolvers, tryOrUndefined } from '@orpc/shared' -import { BaseRedisPublisher } from './base-redis' +import type { Redis } from '@upstash/redis' + +import type { BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis.ts' +import { BaseRedisPublisher } from './base-redis.ts' export interface UpstashPublisherOptions extends BaseRedisPublisherOptions {} @@ -31,7 +32,10 @@ export class UpstashPublisher> extends BaseRedi * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters} */ prefix(prefix: string): UpstashPublisher { - return new UpstashPublisher(this.redis, { ...this.options, prefix: `${this.eventPrefix}${prefix}` }) + return new UpstashPublisher(this.redis, { + ...this.options, + prefix: `${this.eventPrefix}${prefix}`, + }) } protected async publishMessage(channel: string, message: string): Promise { @@ -48,8 +52,7 @@ export class UpstashPublisher> extends BaseRedi try { await this.subscribeIfNeeded(channel) - } - catch (error) { + } catch (error) { remove(this.listenersMap, channel, listener) await this.unsubscribeIfUnused(channel) throw error @@ -76,10 +79,9 @@ export class UpstashPublisher> extends BaseRedi } protected async readStreamEntries(key: string, lastId: string): Promise { - const results = await this.redis.xread(key, lastId) as null | Array<[ - key: string, - entries: Array<[id: string, fields: [name: string, value: unknown]]>, - ]> + const results = (await this.redis.xread(key, lastId)) as null | Array< + [key: string, entries: Array<[id: string, fields: [name: string, value: unknown]]>] + > return results?.[0]?.[1].map(([id, fields]) => ({ id, data: fields[1] })) ?? [] } @@ -99,11 +101,17 @@ export class UpstashPublisher> extends BaseRedi subscription.on('subscribe', () => resolve()) subscription.on('error', (error) => { reject(error) - this.onErrorsMap.get(channel)?.slice().forEach(onError => onError(error)) + this.onErrorsMap + .get(channel) + ?.slice() + .forEach((onError) => onError(error)) }) subscription.on('message', ({ message }) => { // Snapshot, since a listener may unsubscribe itself mid-dispatch. - this.listenersMap.get(channel)?.slice().forEach(listener => tryOrUndefined(() => listener(message))) + this.listenersMap + .get(channel) + ?.slice() + .forEach((listener) => tryOrUndefined(() => listener(message))) }) this.pendingSubscriptionsMap.set(channel, promise) @@ -111,12 +119,10 @@ export class UpstashPublisher> extends BaseRedi try { await promise this.subscriptionMap.set(channel, subscription) - } - catch (error) { + } catch (error) { await subscription.unsubscribe() throw error - } - finally { + } finally { this.pendingSubscriptionsMap.delete(channel) } } @@ -136,7 +142,7 @@ function push(map: Map, key: K, value: V): void { let values = map.get(key) if (!values) { - map.set(key, values = []) + map.set(key, (values = [])) } values.push(value) diff --git a/packages/publisher/src/index.test.ts b/packages/publisher/src/index.test.ts index ba222e1ab5..f05458d0a5 100644 --- a/packages/publisher/src/index.test.ts +++ b/packages/publisher/src/index.test.ts @@ -1,5 +1,5 @@ it('exports Publisher', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ Publisher: expect.any(Function), }) }) diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index cbf944cc89..2d6baa48c8 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -1,5 +1,3 @@ -export * from './publisher' +export * from './publisher.ts' -export type { - AsyncIteratorClass, -} from '@orpc/shared' +export type { AsyncIteratorClass } from '@orpc/shared' diff --git a/packages/publisher/src/publisher.test-d.ts b/packages/publisher/src/publisher.test-d.ts index 7ea6b43f26..84f96b54d0 100644 --- a/packages/publisher/src/publisher.test-d.ts +++ b/packages/publisher/src/publisher.test-d.ts @@ -1,4 +1,4 @@ -import type { Publisher } from './publisher' +import type { Publisher } from './publisher.ts' describe('publisher', () => { it('key-value types', async () => { @@ -11,27 +11,27 @@ describe('publisher', () => { } }> - pub.publish('event-1', { id: '1' }) - pub.publish('event-2', { name: '1' }) + void pub.publish('event-1', { id: '1' }) + void pub.publish('event-2', { name: '1' }) // @ts-expect-error - wrong event - pub.publish('event-3', { name: '1' }) + void pub.publish('event-3', { name: '1' }) // @ts-expect-error - wrong payload - pub.publish('event-2', { name: 123 }) + void pub.publish('event-2', { name: 123 }) - pub.subscribe('event-1', (payload) => { + void pub.subscribe('event-1', (payload) => { expectTypeOf(payload).toEqualTypeOf<{ id: string }>() }) - pub.subscribe('event-2', (payload) => { + void pub.subscribe('event-2', (payload) => { expectTypeOf(payload).toEqualTypeOf<{ name: string }>() }) // @ts-expect-error - wrong event - pub.subscribe('event-3', (payload) => {}) + void pub.subscribe('event-3', (payload) => {}) for await (const payload of pub.subscribe('event-1')) { expectTypeOf(payload).toEqualTypeOf<{ @@ -54,13 +54,13 @@ describe('publisher', () => { it('record types', async () => { const pub = {} as Publisher> - pub.publish('event-1', { id: '1' }) - pub.publish('event-2', { id: '1' }) - pub.publish('event-100', { id: '1' }) + void pub.publish('event-1', { id: '1' }) + void pub.publish('event-2', { id: '1' }) + void pub.publish('event-100', { id: '1' }) // @ts-expect-error - invalid payload - pub.publish('event-100', { id: 123 }) + void pub.publish('event-100', { id: 123 }) - pub.subscribe('event-933', (payload) => { + void pub.subscribe('event-933', (payload) => { expectTypeOf(payload).toEqualTypeOf<{ id: string }>() diff --git a/packages/publisher/src/publisher.test.ts b/packages/publisher/src/publisher.test.ts index bcdad3283d..f914f54bfe 100644 --- a/packages/publisher/src/publisher.test.ts +++ b/packages/publisher/src/publisher.test.ts @@ -1,6 +1,7 @@ -import type { PublisherSubscribeListenerOptions } from './publisher' import { promiseWithResolvers, sleep } from '@orpc/shared' -import { Publisher } from './publisher' + +import type { PublisherSubscribeListenerOptions } from './publisher.ts' +import { Publisher } from './publisher.ts' // Concrete implementation for testing class TestPublisher> extends Publisher { @@ -10,7 +11,7 @@ class TestPublisher> extends Publisher { async publish(event: K, payload: T[K]): Promise { const eventListeners = this.listenersMap.get(event) if (eventListeners) { - eventListeners.forEach(listener => listener(payload)) + eventListeners.forEach((listener) => listener(payload)) } } @@ -38,7 +39,7 @@ class TestPublisher> extends Publisher { type TestEvents = { message: { text: string } count: { value: number } - user: { id: string, name: string } + user: { id: string; name: string } } describe('publisher', () => { @@ -66,11 +67,17 @@ describe('publisher', () => { const unsubscribe = await publisher.subscribe('message', listener, { lastEventId, onError }) expect(subscribeListenerSpy).toHaveBeenCalledTimes(1) - expect(subscribeListenerSpy).toHaveBeenNthCalledWith(1, 'message', listener, { lastEventId, onError }) + expect(subscribeListenerSpy).toHaveBeenNthCalledWith(1, 'message', listener, { + lastEventId, + onError, + }) const iterator = publisher.subscribe('message', { lastEventId }) expect(subscribeListenerSpy).toHaveBeenCalledTimes(2) - expect(subscribeListenerSpy).toHaveBeenNthCalledWith(2, 'message', expect.any(Function), { lastEventId, onError: expect.any(Function) }) + expect(subscribeListenerSpy).toHaveBeenNthCalledWith(2, 'message', expect.any(Function), { + lastEventId, + onError: expect.any(Function), + }) await unsubscribe() await iterator.return() @@ -82,9 +89,9 @@ describe('publisher', () => { const iterator = publisher.subscribe('message') setTimeout(() => { - publisher.publish('message', { text: 'first' }) - publisher.publish('message', { text: 'second' }) - publisher.publish('message', { text: 'third' }) + void publisher.publish('message', { text: 'first' }) + void publisher.publish('message', { text: 'second' }) + void publisher.publish('message', { text: 'third' }) setTimeout(() => iterator.return(), 50) }, 10) @@ -101,10 +108,7 @@ describe('publisher', () => { await publisher.publish('message', { text: 'concurrent' }) - const [result1, result2] = await Promise.all([ - iterator1.next(), - iterator2.next(), - ]) + const [result1, result2] = await Promise.all([iterator1.next(), iterator2.next()]) expect(result1.value?.text).toBe('concurrent') expect(result2.value?.text).toBe('concurrent') @@ -200,7 +204,7 @@ describe('publisher', () => { await publisher.publish('message', { text: 'dropped' }) const nextPromise = iterator.next() - await new Promise(resolve => setTimeout(resolve, 1)) + await new Promise((resolve) => setTimeout(resolve, 1)) await publisher.publish('message', { text: 'received' }) @@ -342,9 +346,18 @@ describe('publisher', () => { publisher.optionsMap.get('message')?.[0]?.onError?.(new Error('Test error')) await publisher.publish('message', { text: 'third' }) - await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { text: 'first' } }) - await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { text: 'second' } }) - await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { text: 'third' } }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { text: 'first' }, + }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { text: 'second' }, + }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { text: 'third' }, + }) await expect(iterator.next()).rejects.toThrow('Test error') }) }) @@ -386,9 +399,18 @@ describe('publisher', () => { listener?.({ text: 'third' }) await sleep(0) - await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { text: 'first' } }) - await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { text: 'second' } }) - await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { text: 'third' } }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { text: 'first' }, + }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { text: 'second' }, + }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { text: 'third' }, + }) await expect(iterator.next()).rejects.toThrow('__TEST__') }) }) diff --git a/packages/publisher/src/publisher.ts b/packages/publisher/src/publisher.ts index c88357d2e6..39ff4115e1 100644 --- a/packages/publisher/src/publisher.ts +++ b/packages/publisher/src/publisher.ts @@ -56,7 +56,9 @@ export interface PublisherSubscribeListenerOptions { } export interface PublisherSubscribeIteratorOptions - extends Pick, Pick { + extends + Pick, + Pick { /** * Abort signal, automatically unsubscribes on abort */ @@ -66,9 +68,7 @@ export interface PublisherSubscribeIteratorOptions export abstract class Publisher> { private readonly maxBufferedEvents: Exclude - constructor( - options: PublisherOptions = {}, - ) { + constructor(options: PublisherOptions = {}) { this.maxBufferedEvents = options.maxBufferedEvents ?? 100 } @@ -87,7 +87,7 @@ export abstract class Publisher> { protected abstract subscribeListener( event: K, listener: (payload: T[K]) => void, - options?: PublisherSubscribeListenerOptions + options?: PublisherSubscribeListenerOptions, ): Promise<() => Promise> /** @@ -109,7 +109,11 @@ export abstract class Publisher> { * unsubscribe() * ``` */ - subscribe(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise> + subscribe( + event: K, + listener: (payload: T[K]) => void, + options?: PublisherSubscribeListenerOptions, + ): Promise<() => Promise> /** * Subscribes to a specific event using an AsyncIteratorObject. * Useful for `for await...of` loops with optional buffering and abort support. @@ -121,7 +125,10 @@ export abstract class Publisher> { * } * ``` */ - subscribe(event: K, options?: PublisherSubscribeIteratorOptions): AsyncIteratorClass + subscribe( + event: K, + options?: PublisherSubscribeIteratorOptions, + ): AsyncIteratorClass subscribe( event: K, listenerOrOptions?: ((payload: T[K]) => void) | PublisherSubscribeIteratorOptions, @@ -137,10 +144,14 @@ export abstract class Publisher> { throwIfAborted(signal) const bufferedEvents = new Queue() - const pullResolvers: { resolve: (result: IteratorResult) => void, reject: (error: Error) => void }[] = [] - - const unsubscribePromise = this - .subscribeListener(event, (payload) => { + const pullResolvers: { + resolve: (result: IteratorResult) => void + reject: (error: Error) => void + }[] = [] + + const unsubscribePromise = this.subscribeListener( + event, + (payload) => { if (signal?.aborted) { return } @@ -149,24 +160,24 @@ export abstract class Publisher> { if (resolver) { resolver.resolve({ done: false, value: payload }) - } - else { + } else { bufferedEvents.push(payload) if (bufferedEvents.length > maxBufferedEvents) { bufferedEvents.shift() } } - }, { + }, + { lastEventId: listenerOrOptions?.lastEventId, - onError: error => terminate({ kind: 'error', error }), - }) - .catch(error => terminate({ kind: 'error', error })) + onError: (error) => terminate({ kind: 'error', error }), + }, + ).catch((error) => terminate({ kind: 'error', error })) const abortListener = () => terminate({ kind: 'error', error: signal!.reason }) signal?.addEventListener('abort', abortListener) - let terminalState: { kind: 'error', error: ThrowableError } | { kind: 'cancelled' } | undefined + let terminalState: { kind: 'error'; error: ThrowableError } | { kind: 'cancelled' } | undefined async function terminate(state: Exclude) { if (terminalState) { return @@ -176,10 +187,9 @@ export abstract class Publisher> { signal?.removeEventListener('abort', abortListener) if (state.kind === 'error') { - pullResolvers.forEach(resolver => resolver.reject(state.error)) - } - else { - pullResolvers.forEach(resolver => resolver.resolve({ done: true, value: undefined })) + pullResolvers.forEach((resolver) => resolver.reject(state.error)) + } else { + pullResolvers.forEach((resolver) => resolver.resolve({ done: true, value: undefined })) } if (signal?.aborted) { @@ -188,7 +198,7 @@ export abstract class Publisher> { } // Keep this async call at the end to avoid race conditions. - await unsubscribePromise.then(unsubscribe => unsubscribe?.()) + await unsubscribePromise.then((unsubscribe) => unsubscribe?.()) } return new AsyncIteratorClass( diff --git a/packages/publisher/tests/redis-adapters-compatibility.test.ts b/packages/publisher/tests/redis-adapters-compatibility.test.ts index 9dd40ab16b..1de2a79ea3 100644 --- a/packages/publisher/tests/redis-adapters-compatibility.test.ts +++ b/packages/publisher/tests/redis-adapters-compatibility.test.ts @@ -1,9 +1,10 @@ -import type { Publisher } from '../src' import { getEventMeta, withEventMeta } from '@standard-server/core' import { Redis } from '@upstash/redis' import { createClient } from 'redis' -import { RedisPublisher } from '../src/adapters/redis' -import { UpstashPublisher } from '../src/adapters/upstash' + +import { RedisPublisher } from '../src/adapters/redis.ts' +import { UpstashPublisher } from '../src/adapters/upstash.ts' +import type { Publisher } from '../src/index.ts' const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN @@ -17,11 +18,13 @@ const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN * all adapters must connect to the same server. */ describe('publisher redis adapters compatibility', { timeout: 20_000 }, () => { - const publishers: Array<{ name: string, publisher: Publisher }> = [] + const publishers: Array<{ name: string; publisher: Publisher }> = [] const prefix = `redis-adapters:${crypto.randomUUID()}` if (UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN) { - const redis = createClient({ url: `rediss://default:${UPSTASH_REDIS_REST_TOKEN}@${new URL(UPSTASH_REDIS_REST_URL).host}:6379` }) + const redis = createClient({ + url: `rediss://default:${UPSTASH_REDIS_REST_TOKEN}@${new URL(UPSTASH_REDIS_REST_URL).host}:6379`, + }) const publisher = new RedisPublisher(redis, { prefix, resume: { enabled: true, ttl: 10_000 }, @@ -30,7 +33,11 @@ describe('publisher redis adapters compatibility', { timeout: 20_000 }, () => { } // TODO: Upstash is not compatible with Node 26 yet — temporarily disable these tests and revisit in the future. - if (UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN && !process.versions.node.startsWith('26.')) { + if ( + UPSTASH_REDIS_REST_URL && + UPSTASH_REDIS_REST_TOKEN && + !process.versions.node.startsWith('26.') + ) { const upstashRedis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }) const publisher = new UpstashPublisher(upstashRedis, { prefix, @@ -52,7 +59,10 @@ describe('publisher redis adapters compatibility', { timeout: 20_000 }, () => { const unsubscribeLive = await target.publisher.subscribe(event, liveListener) - await source.publisher.publish(event, withEventMeta({ order: 1 }, { id: 'client-id', comments: ['cross-adapter'] })) + await source.publisher.publish( + event, + withEventMeta({ order: 1 }, { id: 'client-id', comments: ['cross-adapter'] }), + ) await source.publisher.publish(event, { order: 2 }) await vi.waitFor(() => { diff --git a/packages/publisher/tsconfig.json b/packages/publisher/tsconfig.json index 04f5f1f4b1..9979a0715c 100644 --- a/packages/publisher/tsconfig.json +++ b/packages/publisher/tsconfig.json @@ -1,16 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.bench.*", - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 607144060f..b6daa09f17 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/ratelimit/build.config.ts b/packages/ratelimit/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/ratelimit/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/ratelimit/package.json b/packages/ratelimit/package.json index ac550f661c..0215db4c54 100644 --- a/packages/ratelimit/package.json +++ b/packages/ratelimit/package.json @@ -1,74 +1,82 @@ { "name": "@orpc/ratelimit", - "type": "module", "version": "2.0.0-beta.48", "description": "Rate limiting for oRPC procedures, with memory, Redis, and Upstash adapters", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "api", + "middleware", + "orpc", + "rate-limiting", + "ratelimit", + "redis", + "typescript", + "upstash" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/ratelimit" }, - "keywords": [ - "orpc", - "ratelimit", - "rate-limiting", - "redis", - "upstash", - "middleware", - "api", - "typescript" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./base-redis": "./src/adapters/base-redis.ts", + "./memory": "./src/adapters/memory.ts", + "./redis": "./src/adapters/redis.ts", + "./upstash": "./src/adapters/upstash.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./base-redis": { - "types": "./dist/adapters/base-redis.d.mts", - "import": "./dist/adapters/base-redis.mjs", - "default": "./dist/adapters/base-redis.mjs" + "types": "./dist/adapters/base-redis.d.ts", + "import": "./dist/adapters/base-redis.js", + "default": "./dist/adapters/base-redis.js" }, "./memory": { - "types": "./dist/adapters/memory.d.mts", - "import": "./dist/adapters/memory.mjs", - "default": "./dist/adapters/memory.mjs" + "types": "./dist/adapters/memory.d.ts", + "import": "./dist/adapters/memory.js", + "default": "./dist/adapters/memory.js" }, "./redis": { - "types": "./dist/adapters/redis.d.mts", - "import": "./dist/adapters/redis.mjs", - "default": "./dist/adapters/redis.mjs" + "types": "./dist/adapters/redis.d.ts", + "import": "./dist/adapters/redis.js", + "default": "./dist/adapters/redis.js" }, "./upstash": { - "types": "./dist/adapters/upstash.d.mts", - "import": "./dist/adapters/upstash.mjs", - "default": "./dist/adapters/upstash.mjs" + "types": "./dist/adapters/upstash.d.ts", + "import": "./dist/adapters/upstash.js", + "default": "./dist/adapters/upstash.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts", - "./base-redis": "./src/adapters/base-redis.ts", - "./memory": "./src/adapters/memory.ts", - "./redis": "./src/adapters/redis.ts", - "./upstash": "./src/adapters/upstash.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" + "prepack": "tsc" + }, + "dependencies": { + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*", + "@standard-server/core": "~0.14.0" + }, + "devDependencies": { + "@upstash/redis": "^1.41.0", + "redis": "^6.3.1" }, "peerDependencies": { "@upstash/redis": ">=1.35.6", @@ -81,14 +89,5 @@ "redis": { "optional": true } - }, - "dependencies": { - "@orpc/server": "workspace:*", - "@orpc/shared": "workspace:*", - "@standard-server/core": "~0.14.0" - }, - "devDependencies": { - "@upstash/redis": "^1.41.0", - "redis": "^6.3.1" } } diff --git a/packages/ratelimit/src/adapters/base-redis.ts b/packages/ratelimit/src/adapters/base-redis.ts index dda1b035bb..3936b5f9ec 100644 --- a/packages/ratelimit/src/adapters/base-redis.ts +++ b/packages/ratelimit/src/adapters/base-redis.ts @@ -1,5 +1,5 @@ -import type { RateLimiter, RateLimitOptions, RateLimitResult } from '../types' -import { blockUntilReady, resolveWeight } from '../utils' +import type { RateLimiter, RateLimitOptions, RateLimitResult } from '../types.ts' +import { blockUntilReady, resolveWeight } from '../utils.ts' /** * Consumes `ARGV[1]` points from the counter at `KEYS[1]`, starts a window of @@ -75,11 +75,11 @@ export abstract class BaseRedisRateLimiter implements RateLimiter { } private async checkLimit(key: string, weight: number): Promise> { - const [used, ttl] = await this.evalScript( + const [used, ttl] = (await this.evalScript( FIXED_WINDOW_SCRIPT, [key], [String(weight), String(this.window)], - ) as [used: number, ttl: number] + )) as [used: number, ttl: number] return { success: used <= this.maxRequests, diff --git a/packages/ratelimit/src/adapters/memory.test.ts b/packages/ratelimit/src/adapters/memory.test.ts index da91de8bbf..3db7a54fff 100644 --- a/packages/ratelimit/src/adapters/memory.test.ts +++ b/packages/ratelimit/src/adapters/memory.test.ts @@ -1,4 +1,4 @@ -import { MemoryRateLimiter } from './memory' +import { MemoryRateLimiter } from './memory.ts' describe('memoryRateLimiter', () => { beforeEach(() => { @@ -13,52 +13,147 @@ describe('memoryRateLimiter', () => { it('allows requests up to the limit, then starts denying', async () => { const limiter = new MemoryRateLimiter({ maxRequests: 3, window: 1000 }) - expect(await limiter.limit('key')).toEqual({ success: true, limit: 3, remaining: 2, reset: 1000 }) - expect(await limiter.limit('key')).toEqual({ success: true, limit: 3, remaining: 1, reset: 1000 }) - expect(await limiter.limit('key')).toEqual({ success: true, limit: 3, remaining: 0, reset: 1000 }) - expect(await limiter.limit('key')).toEqual({ success: false, limit: 3, remaining: 0, reset: 1000 }) + expect(await limiter.limit('key')).toEqual({ + success: true, + limit: 3, + remaining: 2, + reset: 1000, + }) + expect(await limiter.limit('key')).toEqual({ + success: true, + limit: 3, + remaining: 1, + reset: 1000, + }) + expect(await limiter.limit('key')).toEqual({ + success: true, + limit: 3, + remaining: 0, + reset: 1000, + }) + expect(await limiter.limit('key')).toEqual({ + success: false, + limit: 3, + remaining: 0, + reset: 1000, + }) }) it('consumes weight and denies when accumulated weight exceeds the limit', async () => { const limiter = new MemoryRateLimiter({ maxRequests: 5, window: 1000 }) - expect(await limiter.limit('key', { weight: 3 })).toEqual({ success: true, limit: 5, remaining: 2, reset: 1000 }) - expect(await limiter.limit('key', { weight: 3 })).toEqual({ success: false, limit: 5, remaining: 2, reset: 1000 }) - expect(await limiter.limit('key', { weight: 2 })).toEqual({ success: true, limit: 5, remaining: 0, reset: 1000 }) - expect(await limiter.limit('key', { weight: 1 })).toEqual({ success: false, limit: 5, remaining: 0, reset: 1000 }) + expect(await limiter.limit('key', { weight: 3 })).toEqual({ + success: true, + limit: 5, + remaining: 2, + reset: 1000, + }) + expect(await limiter.limit('key', { weight: 3 })).toEqual({ + success: false, + limit: 5, + remaining: 2, + reset: 1000, + }) + expect(await limiter.limit('key', { weight: 2 })).toEqual({ + success: true, + limit: 5, + remaining: 0, + reset: 1000, + }) + expect(await limiter.limit('key', { weight: 1 })).toEqual({ + success: false, + limit: 5, + remaining: 0, + reset: 1000, + }) }) it('tracks limits independently per key', async () => { const limiter = new MemoryRateLimiter({ maxRequests: 1, window: 1000 }) - expect(await limiter.limit('alice')).toEqual({ success: true, limit: 1, remaining: 0, reset: 1000 }) - expect(await limiter.limit('alice')).toEqual({ success: false, limit: 1, remaining: 0, reset: 1000 }) - expect(await limiter.limit('bob')).toEqual ({ success: true, limit: 1, remaining: 0, reset: 1000 }) + expect(await limiter.limit('alice')).toEqual({ + success: true, + limit: 1, + remaining: 0, + reset: 1000, + }) + expect(await limiter.limit('alice')).toEqual({ + success: false, + limit: 1, + remaining: 0, + reset: 1000, + }) + expect(await limiter.limit('bob')).toEqual({ + success: true, + limit: 1, + remaining: 0, + reset: 1000, + }) }) it('denies within window and resets counter on new epoch', async () => { const limiter = new MemoryRateLimiter({ maxRequests: 2, window: 1000 }) - expect(await limiter.limit('key')).toEqual({ success: true, limit: 2, remaining: 1, reset: 1000 }) - expect(await limiter.limit('key')).toEqual({ success: true, limit: 2, remaining: 0, reset: 1000 }) + expect(await limiter.limit('key')).toEqual({ + success: true, + limit: 2, + remaining: 1, + reset: 1000, + }) + expect(await limiter.limit('key')).toEqual({ + success: true, + limit: 2, + remaining: 0, + reset: 1000, + }) await vi.advanceTimersByTimeAsync(999) // still inside window - expect(await limiter.limit('key')).toEqual({ success: false, limit: 2, remaining: 0, reset: 1000 }) + expect(await limiter.limit('key')).toEqual({ + success: false, + limit: 2, + remaining: 0, + reset: 1000, + }) await vi.advanceTimersByTimeAsync(1) // t=1000 — new epoch - expect(await limiter.limit('key', { weight: 2 })).toEqual({ success: true, limit: 2, remaining: 0, reset: 2000 }) - expect(await limiter.limit('key')).toEqual ({ success: false, limit: 2, remaining: 0, reset: 2000 }) + expect(await limiter.limit('key', { weight: 2 })).toEqual({ + success: true, + limit: 2, + remaining: 0, + reset: 2000, + }) + expect(await limiter.limit('key')).toEqual({ + success: false, + limit: 2, + remaining: 0, + reset: 2000, + }) }) it('reset is pegged to epoch start and does not shift mid-window', async () => { vi.setSystemTime(200) const limiter = new MemoryRateLimiter({ maxRequests: 3, window: 1000 }) - expect(await limiter.limit('key', { weight: 2 })).toEqual({ success: true, limit: 3, remaining: 1, reset: 1200 }) + expect(await limiter.limit('key', { weight: 2 })).toEqual({ + success: true, + limit: 3, + remaining: 1, + reset: 1200, + }) vi.setSystemTime(500) - expect(await limiter.limit('key', { weight: 1 })).toEqual({ success: true, limit: 3, remaining: 0, reset: 1200 }) + expect(await limiter.limit('key', { weight: 1 })).toEqual({ + success: true, + limit: 3, + remaining: 0, + reset: 1200, + }) vi.setSystemTime(600) - expect(await limiter.limit('key', { weight: 2 })).toEqual({ success: false, limit: 3, remaining: 0, reset: 1200 }) + expect(await limiter.limit('key', { weight: 2 })).toEqual({ + success: false, + limit: 3, + remaining: 0, + reset: 1200, + }) }) it('throws for invalid weights', async () => { @@ -77,11 +172,9 @@ describe('memoryRateLimiter', () => { window: 1000, }) - const results = await Promise.all( - Array.from({ length: 100 }, () => limiter.limit('user')), - ) + const results = await Promise.all(Array.from({ length: 100 }, () => limiter.limit('user'))) - const successCount = results.filter(r => r.success).length + const successCount = results.filter((r) => r.success).length expect(successCount).toBe(10) expect(results.length - successCount).toBe(90) @@ -95,7 +188,12 @@ describe('memoryRateLimiter', () => { timeout: 1000, }) - expect(await limiter.limit('key')).toEqual({ success: true, limit: 1, remaining: 0, reset: 500 }) + expect(await limiter.limit('key')).toEqual({ + success: true, + limit: 1, + remaining: 0, + reset: 500, + }) const pending = limiter.limit('key') await vi.advanceTimersByTimeAsync(500) // new epoch at t=500 @@ -110,7 +208,12 @@ describe('memoryRateLimiter', () => { timeout: 1000, }) - expect(await limiter.limit('key', { weight: 3 })).toEqual({ success: true, limit: 3, remaining: 0, reset: 500 }) + expect(await limiter.limit('key', { weight: 3 })).toEqual({ + success: true, + limit: 3, + remaining: 0, + reset: 500, + }) const pending = limiter.limit('key', { weight: 2 }) await vi.advanceTimersByTimeAsync(500) // new epoch at t=500 @@ -125,7 +228,12 @@ describe('memoryRateLimiter', () => { timeout: 100, }) - expect(await limiter.limit('key')).toEqual({ success: true, limit: 1, remaining: 0, reset: 2000 }) + expect(await limiter.limit('key')).toEqual({ + success: true, + limit: 1, + remaining: 0, + reset: 2000, + }) const pending = limiter.limit('key') await vi.advanceTimersByTimeAsync(200) @@ -140,8 +248,18 @@ describe('memoryRateLimiter', () => { timeout: 0, }) - expect(await limiter.limit('key')).toEqual({ success: true, limit: 1, remaining: 0, reset: 1000 }) - expect(await limiter.limit('key')).toEqual({ success: false, limit: 1, remaining: 0, reset: 1000 }) + expect(await limiter.limit('key')).toEqual({ + success: true, + limit: 1, + remaining: 0, + reset: 1000, + }) + expect(await limiter.limit('key')).toEqual({ + success: false, + limit: 1, + remaining: 0, + reset: 1000, + }) }) }) diff --git a/packages/ratelimit/src/adapters/memory.ts b/packages/ratelimit/src/adapters/memory.ts index 6834d003b0..f688d83d32 100644 --- a/packages/ratelimit/src/adapters/memory.ts +++ b/packages/ratelimit/src/adapters/memory.ts @@ -1,5 +1,5 @@ -import type { RateLimiter, RateLimitOptions, RateLimitResult } from '../types' -import { blockUntilReady, resolveWeight } from '../utils' +import type { RateLimiter, RateLimitOptions, RateLimitResult } from '../types.ts' +import { blockUntilReady, resolveWeight } from '../utils.ts' export interface MemoryRateLimiterOptions { /** diff --git a/packages/ratelimit/src/adapters/redis.test.ts b/packages/ratelimit/src/adapters/redis.test.ts index 0531b8df58..e1ae19e40f 100644 --- a/packages/ratelimit/src/adapters/redis.test.ts +++ b/packages/ratelimit/src/adapters/redis.test.ts @@ -1,307 +1,291 @@ import { sleep } from '@orpc/shared' import { createClient } from 'redis' -import { RedisRateLimiter } from './redis' + +import { RedisRateLimiter } from './redis.ts' const REDIS_URL = process.env.REDIS_URL -describe.concurrent('redis rate limiter integration', { - skip: !REDIS_URL, - timeout: 20_000, -}, async () => { - const redis = createClient({ - url: REDIS_URL, - }) - - beforeAll(async () => { - await redis.connect() - }) - - async function createTestingRateLimiter( - options: ConstructorParameters[1] = { - maxRequests: 3, - window: 10_000, - }, - ) { - return new RedisRateLimiter(redis, { - prefix: `orpc-redis-rate-limiter-${crypto.randomUUID()}:`, - ...options, +describe.concurrent( + 'redis rate limiter integration', + { + skip: !REDIS_URL, + timeout: 20_000, + }, + async () => { + const redis = createClient({ + url: REDIS_URL, }) - } - - it('accepts weighted requests', async () => { - const limiter = await createTestingRateLimiter() - const key = 'weighted' - - await expect( - limiter.limit(key, { weight: 2 }), - ).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 1, + + beforeAll(async () => { + await redis.connect() }) - }) - it('tracks counters independently for each key', async () => { - const limiter = await createTestingRateLimiter() + async function createTestingRateLimiter( + options: ConstructorParameters[1] = { + maxRequests: 3, + window: 10_000, + }, + ) { + return new RedisRateLimiter(redis, { + prefix: `orpc-redis-rate-limiter-${crypto.randomUUID()}:`, + ...options, + }) + } - const aliceKey = 'alice' - const bobKey = 'bob' + it('accepts weighted requests', async () => { + const limiter = await createTestingRateLimiter() + const key = 'weighted' - await expect( - limiter.limit(aliceKey), - ).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 2, + await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 1, + }) }) - await expect( - limiter.limit(bobKey), - ).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 2, - }) - }) + it('tracks counters independently for each key', async () => { + const limiter = await createTestingRateLimiter() - it('shares counters with limiters created by prefix', async () => { - const limiter = await createTestingRateLimiter({ maxRequests: 1, window: 10_000 }) + const aliceKey = 'alice' + const bobKey = 'bob' - await expect(limiter.limit('tenant:key')).resolves.toMatchObject({ success: true, remaining: 0 }) - await expect(limiter.prefix('tenant:').limit('key')).resolves.toMatchObject({ success: false, remaining: 0 }) - await expect(limiter.prefix('other:').limit('key')).resolves.toMatchObject({ success: true, remaining: 0 }) - }) + await expect(limiter.limit(aliceKey)).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 2, + }) - it('returns a stable reset timestamp within the same window', async () => { - const window = 2000 - const limiter = await createTestingRateLimiter({ - maxRequests: 2, - window, + await expect(limiter.limit(bobKey)).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 2, + }) }) - const key = 'reset' - const tolerance = 500 - const firstStartedAt = Date.now() - const first = await limiter.limit(key) - const firstCompletedAt = Date.now() + it('shares counters with limiters created by prefix', async () => { + const limiter = await createTestingRateLimiter({ maxRequests: 1, window: 10_000 }) - expect(first).toMatchObject({ - success: true, - limit: 2, - remaining: 1, + await expect(limiter.limit('tenant:key')).resolves.toMatchObject({ + success: true, + remaining: 0, + }) + await expect(limiter.prefix('tenant:').limit('key')).resolves.toMatchObject({ + success: false, + remaining: 0, + }) + await expect(limiter.prefix('other:').limit('key')).resolves.toMatchObject({ + success: true, + remaining: 0, + }) }) - expect(first.reset).toBeGreaterThanOrEqual(firstStartedAt + window - tolerance) - expect(first.reset).toBeLessThanOrEqual(firstCompletedAt + window) - await sleep(100) + it('returns a stable reset timestamp within the same window', async () => { + const window = 2000 + const limiter = await createTestingRateLimiter({ + maxRequests: 2, + window, + }) + const key = 'reset' + const tolerance = 500 - const second = await limiter.limit(key) + const firstStartedAt = Date.now() + const first = await limiter.limit(key) + const firstCompletedAt = Date.now() - expect(second).toMatchObject({ - success: true, - limit: 2, - remaining: 0, - }) - expect(Math.abs(second.reset - first.reset)).toBeLessThanOrEqual(tolerance) + expect(first).toMatchObject({ + success: true, + limit: 2, + remaining: 1, + }) + expect(first.reset).toBeGreaterThanOrEqual(firstStartedAt + window - tolerance) + expect(first.reset).toBeLessThanOrEqual(firstCompletedAt + window) - const denied = await limiter.limit(key) + await sleep(100) - expect(denied).toMatchObject({ - success: false, - limit: 2, - remaining: 0, - }) - expect(Math.abs(denied.reset - first.reset)).toBeLessThanOrEqual(tolerance) - }) - - it('uses an empty prefix when none is provided', async () => { - const limiter = await createTestingRateLimiter({ - prefix: undefined, - maxRequests: 3, - window: 10_000, + const second = await limiter.limit(key) + + expect(second).toMatchObject({ + success: true, + limit: 2, + remaining: 0, + }) + expect(Math.abs(second.reset - first.reset)).toBeLessThanOrEqual(tolerance) + + const denied = await limiter.limit(key) + + expect(denied).toMatchObject({ + success: false, + limit: 2, + remaining: 0, + }) + expect(Math.abs(denied.reset - first.reset)).toBeLessThanOrEqual(tolerance) }) - await expect( - limiter.limit(`no-prefix-${crypto.randomUUID()}`), - ).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 2, + it('uses an empty prefix when none is provided', async () => { + const limiter = await createTestingRateLimiter({ + prefix: undefined, + maxRequests: 3, + window: 10_000, + }) + + await expect(limiter.limit(`no-prefix-${crypto.randomUUID()}`)).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 2, + }) }) - }) - it('propagates client errors', async () => { - const disconnectedRedis = createClient({ - url: 'rediss://invalid', + it('propagates client errors', async () => { + const disconnectedRedis = createClient({ + url: 'rediss://invalid', + }) + const limiter = new RedisRateLimiter(disconnectedRedis, { + maxRequests: 3, + window: 10_000, + }) + + await expect(limiter.limit('closed-client')).rejects.toThrow() }) - const limiter = new RedisRateLimiter(disconnectedRedis, { - maxRequests: 3, - window: 10_000, + + it('rejects invalid weights', async () => { + const limiter = await createTestingRateLimiter() + const key = 'invalid-weight' + + await expect(limiter.limit(key, { weight: 0 })).rejects.toThrow(TypeError) + + await expect(limiter.limit(key, { weight: -1 })).rejects.toThrow(TypeError) + + await expect(limiter.limit(key, { weight: 1.5 })).rejects.toThrow( + 'Rate limit weight must be an integer greater than 0', + ) }) - await expect( - limiter.limit('closed-client'), - ).rejects.toThrow() - }) - - it('rejects invalid weights', async () => { - const limiter = await createTestingRateLimiter() - const key = 'invalid-weight' - - await expect( - limiter.limit(key, { weight: 0 }), - ).rejects.toThrow(TypeError) - - await expect( - limiter.limit(key, { weight: -1 }), - ).rejects.toThrow(TypeError) - - await expect( - limiter.limit(key, { weight: 1.5 }), - ).rejects.toThrow( - 'Rate limit weight must be an integer greater than 0', - ) - }) - - it('consumes quota across multiple requests', async () => { - const limiter = await createTestingRateLimiter() - const key = 'quota' - - await expect(limiter.limit(key)) - .resolves - .toMatchObject({ + it('consumes quota across multiple requests', async () => { + const limiter = await createTestingRateLimiter() + const key = 'quota' + + await expect(limiter.limit(key)).resolves.toMatchObject({ success: true, remaining: 2, }) - await expect(limiter.limit(key)) - .resolves - .toMatchObject({ + await expect(limiter.limit(key)).resolves.toMatchObject({ success: true, remaining: 1, }) - await expect(limiter.limit(key)) - .resolves - .toMatchObject({ + await expect(limiter.limit(key)).resolves.toMatchObject({ success: true, remaining: 0, }) - }) - - it('rejects requests that exceed the limit', async () => { - const limiter = await createTestingRateLimiter() - const key = 'exceeded' - - await limiter.limit(key) - await limiter.limit(key) - await limiter.limit(key) - - await expect( - limiter.limit(key), - ).resolves.toMatchObject({ - success: false, - limit: 3, - remaining: 0, }) - }) - - describe('timeout', () => { - it('waits until the next window when capacity is unavailable', async () => { - const limiter = await createTestingRateLimiter({ - maxRequests: 2, - window: 1000, - timeout: 4000, - }) - const key = 'blocking' + it('rejects requests that exceed the limit', async () => { + const limiter = await createTestingRateLimiter() + const key = 'exceeded' + await limiter.limit(key) await limiter.limit(key) await limiter.limit(key) - const start = Date.now() + await expect(limiter.limit(key)).resolves.toMatchObject({ + success: false, + limit: 3, + remaining: 0, + }) + }) - const result = await limiter.limit(key) + describe('timeout', () => { + it('waits until the next window when capacity is unavailable', async () => { + const limiter = await createTestingRateLimiter({ + maxRequests: 2, + window: 1000, + timeout: 4000, + }) - expect(result).toMatchObject({ - success: true, - limit: 2, - }) + const key = 'blocking' - expect(Date.now() - start).toBeGreaterThanOrEqual(900) - expect(result.reset - Date.now()).toBeGreaterThanOrEqual(700) - }) + await limiter.limit(key) + await limiter.limit(key) - it('waits until enough quota is available for weighted requests', async () => { - const limiter = await createTestingRateLimiter({ - maxRequests: 2, - window: 1000, - timeout: 4000, - }) + const start = Date.now() - const key = 'blocking-weighted' + const result = await limiter.limit(key) - await expect( - limiter.limit(key), - ).resolves.toMatchObject({ - success: true, - remaining: 1, + expect(result).toMatchObject({ + success: true, + limit: 2, + }) + + expect(Date.now() - start).toBeGreaterThanOrEqual(900) + expect(result.reset - Date.now()).toBeGreaterThanOrEqual(700) }) - await sleep(100) + it('waits until enough quota is available for weighted requests', async () => { + const limiter = await createTestingRateLimiter({ + maxRequests: 2, + window: 1000, + timeout: 4000, + }) - await expect( - limiter.limit(key, { weight: 2 }), - ).resolves.toMatchObject({ - success: true, - limit: 2, - remaining: 0, - }) - }) + const key = 'blocking-weighted' - it('returns denial when reset exceeds timeout', async () => { - const limiter = await createTestingRateLimiter({ - maxRequests: 2, - window: 10_000, - timeout: 100, + await expect(limiter.limit(key)).resolves.toMatchObject({ + success: true, + remaining: 1, + }) + + await sleep(100) + + await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ + success: true, + limit: 2, + remaining: 0, + }) }) - const key = 'blocking-timeout' + it('returns denial when reset exceeds timeout', async () => { + const limiter = await createTestingRateLimiter({ + maxRequests: 2, + window: 10_000, + timeout: 100, + }) - await limiter.limit(key) + const key = 'blocking-timeout' - await sleep(100) + await limiter.limit(key) - await expect( - limiter.limit(key, { weight: 2 }), - ).resolves.toMatchObject({ - success: false, - limit: 2, - remaining: 0, + await sleep(100) + + await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ + success: false, + limit: 2, + remaining: 0, + }) }) }) - }) - it('lazily connects to Redis once under concurrent limit calls', async () => { - const redis = createClient({ - url: REDIS_URL, - }) + it('lazily connects to Redis once under concurrent limit calls', async () => { + const redis = createClient({ + url: REDIS_URL, + }) - const limiter = new RedisRateLimiter(redis, { - prefix: `orpc-redis-rate-limiter-${crypto.randomUUID()}:`, - maxRequests: 4, - window: 10, - }) + const limiter = new RedisRateLimiter(redis, { + prefix: `orpc-redis-rate-limiter-${crypto.randomUUID()}:`, + maxRequests: 4, + window: 10, + }) - expect(redis.isOpen).toBe(false) + expect(redis.isOpen).toBe(false) - await Promise.all([ - expect(limiter.limit('key')).resolves.toMatchObject({ success: true }), - expect(limiter.limit('key')).resolves.toMatchObject({ success: true }), - expect(limiter.limit('key')).resolves.toMatchObject({ success: true }), - expect(limiter.limit('key')).resolves.toMatchObject({ success: true }), - ]) + await Promise.all([ + expect(limiter.limit('key')).resolves.toMatchObject({ success: true }), + expect(limiter.limit('key')).resolves.toMatchObject({ success: true }), + expect(limiter.limit('key')).resolves.toMatchObject({ success: true }), + expect(limiter.limit('key')).resolves.toMatchObject({ success: true }), + ]) - expect(redis.isOpen).toBe(true) - }) -}) + expect(redis.isOpen).toBe(true) + }) + }, +) diff --git a/packages/ratelimit/src/adapters/redis.ts b/packages/ratelimit/src/adapters/redis.ts index 0605c75c2d..8f02c5acb0 100644 --- a/packages/ratelimit/src/adapters/redis.ts +++ b/packages/ratelimit/src/adapters/redis.ts @@ -1,6 +1,7 @@ import type { RedisClientType } from 'redis' -import type { BaseRedisRateLimiterOptions } from './base-redis' -import { BaseRedisRateLimiter } from './base-redis' + +import type { BaseRedisRateLimiterOptions } from './base-redis.ts' +import { BaseRedisRateLimiter } from './base-redis.ts' export interface RedisRateLimiterOptions extends BaseRedisRateLimiterOptions {} @@ -24,7 +25,10 @@ export class RedisRateLimiter extends BaseRedisRateLimiter { * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} */ prefix(prefix: string): RedisRateLimiter { - return new RedisRateLimiter(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + return new RedisRateLimiter(this.redis, { + ...this.options, + prefix: `${this.keyPrefix}${prefix}`, + }) } protected async evalScript(script: string, keys: string[], args: string[]): Promise { diff --git a/packages/ratelimit/src/adapters/upstash.test.ts b/packages/ratelimit/src/adapters/upstash.test.ts index 918bd5bed6..5f896c6348 100644 --- a/packages/ratelimit/src/adapters/upstash.test.ts +++ b/packages/ratelimit/src/adapters/upstash.test.ts @@ -1,6 +1,7 @@ import { sleep } from '@orpc/shared' import { Redis } from '@upstash/redis' -import { UpstashRateLimiter } from './upstash' + +import { UpstashRateLimiter } from './upstash.ts' const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN @@ -10,203 +11,211 @@ const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN * `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` envs. * Each limiter gets a random prefix, so keys never conflict between test cases. */ -describe.concurrent('upstash rate limiter integration', { - skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, - timeout: 20_000, -}, () => { - const redis = new Redis({ - url: UPSTASH_REDIS_REST_URL, - token: UPSTASH_REDIS_REST_TOKEN, - }) - - function createTestingRateLimiter( - options: ConstructorParameters[1] = { - maxRequests: 3, - window: 10_000, - }, - ) { - return new UpstashRateLimiter(redis, { - prefix: `orpc-upstash-rate-limiter-${crypto.randomUUID()}:`, - ...options, +describe.concurrent( + 'upstash rate limiter integration', + { + skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, + timeout: 20_000, + }, + () => { + const redis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, }) - } - it('accepts weighted requests', async () => { - const limiter = createTestingRateLimiter() + function createTestingRateLimiter( + options: ConstructorParameters[1] = { + maxRequests: 3, + window: 10_000, + }, + ) { + return new UpstashRateLimiter(redis, { + prefix: `orpc-upstash-rate-limiter-${crypto.randomUUID()}:`, + ...options, + }) + } - await expect(limiter.limit('weighted', { weight: 2 })).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 1, + it('accepts weighted requests', async () => { + const limiter = createTestingRateLimiter() + + await expect(limiter.limit('weighted', { weight: 2 })).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 1, + }) }) - }) - it('tracks counters independently for each key', async () => { - const limiter = createTestingRateLimiter() + it('tracks counters independently for each key', async () => { + const limiter = createTestingRateLimiter() - await expect(limiter.limit('alice')).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 2, - }) + await expect(limiter.limit('alice')).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 2, + }) - await expect(limiter.limit('bob')).resolves.toMatchObject({ - success: true, - limit: 3, - remaining: 2, + await expect(limiter.limit('bob')).resolves.toMatchObject({ + success: true, + limit: 3, + remaining: 2, + }) }) - }) - it('returns a stable reset timestamp within the same window', async () => { - const window = 2000 - const limiter = createTestingRateLimiter({ - maxRequests: 2, - window, - }) - const key = 'reset' - const tolerance = 500 + it('returns a stable reset timestamp within the same window', async () => { + const window = 2000 + const limiter = createTestingRateLimiter({ + maxRequests: 2, + window, + }) + const key = 'reset' + const tolerance = 500 - const firstStartedAt = Date.now() - const first = await limiter.limit(key) - const firstCompletedAt = Date.now() + const firstStartedAt = Date.now() + const first = await limiter.limit(key) + const firstCompletedAt = Date.now() - expect(first).toMatchObject({ - success: true, - limit: 2, - remaining: 1, - }) - expect(first.reset).toBeGreaterThanOrEqual(firstStartedAt + window - tolerance) - expect(first.reset).toBeLessThanOrEqual(firstCompletedAt + window) + expect(first).toMatchObject({ + success: true, + limit: 2, + remaining: 1, + }) + expect(first.reset).toBeGreaterThanOrEqual(firstStartedAt + window - tolerance) + expect(first.reset).toBeLessThanOrEqual(firstCompletedAt + window) - await sleep(100) + await sleep(100) - const second = await limiter.limit(key) + const second = await limiter.limit(key) - expect(second).toMatchObject({ - success: true, - limit: 2, - remaining: 0, - }) - expect(Math.abs(second.reset - first.reset)).toBeLessThanOrEqual(tolerance) + expect(second).toMatchObject({ + success: true, + limit: 2, + remaining: 0, + }) + expect(Math.abs(second.reset - first.reset)).toBeLessThanOrEqual(tolerance) - const denied = await limiter.limit(key) + const denied = await limiter.limit(key) - expect(denied).toMatchObject({ - success: false, - limit: 2, - remaining: 0, - }) - expect(Math.abs(denied.reset - first.reset)).toBeLessThanOrEqual(tolerance) - }) - - it('propagates client errors', async () => { - const invalidRedis = new Redis({ - url: 'http://invalid:6379', - token: 'invalid', - retry: false, - }) - const limiter = new UpstashRateLimiter(invalidRedis, { - maxRequests: 3, - window: 10_000, + expect(denied).toMatchObject({ + success: false, + limit: 2, + remaining: 0, + }) + expect(Math.abs(denied.reset - first.reset)).toBeLessThanOrEqual(tolerance) }) - await expect(limiter.limit('invalid-client')).rejects.toThrow() - }) - - it('rejects invalid weights', async () => { - const limiter = createTestingRateLimiter() - const key = 'invalid-weight' - - await expect(limiter.limit(key, { weight: 0 })).rejects.toThrow(TypeError) - await expect(limiter.limit(key, { weight: -1 })).rejects.toThrow(TypeError) - await expect(limiter.limit(key, { weight: 1.5 })).rejects.toThrow( - 'Rate limit weight must be an integer greater than 0', - ) - }) + it('propagates client errors', async () => { + const invalidRedis = new Redis({ + url: 'http://invalid:6379', + token: 'invalid', + retry: false, + }) + const limiter = new UpstashRateLimiter(invalidRedis, { + maxRequests: 3, + window: 10_000, + }) - it('rejects requests that exceed the limit', async () => { - const limiter = createTestingRateLimiter() - const key = 'exceeded' + await expect(limiter.limit('invalid-client')).rejects.toThrow() + }) - await limiter.limit(key) - await limiter.limit(key) - await limiter.limit(key) + it('rejects invalid weights', async () => { + const limiter = createTestingRateLimiter() + const key = 'invalid-weight' - await expect(limiter.limit(key)).resolves.toMatchObject({ - success: false, - limit: 3, - remaining: 0, + await expect(limiter.limit(key, { weight: 0 })).rejects.toThrow(TypeError) + await expect(limiter.limit(key, { weight: -1 })).rejects.toThrow(TypeError) + await expect(limiter.limit(key, { weight: 1.5 })).rejects.toThrow( + 'Rate limit weight must be an integer greater than 0', + ) }) - }) - describe('timeout', () => { - it('waits until the next window when capacity is unavailable', async () => { - const limiter = createTestingRateLimiter({ - maxRequests: 2, - window: 1000, - timeout: 4000, - }) - const key = 'blocking' + it('rejects requests that exceed the limit', async () => { + const limiter = createTestingRateLimiter() + const key = 'exceeded' + await limiter.limit(key) await limiter.limit(key) await limiter.limit(key) - const start = Date.now() - const result = await limiter.limit(key) - - expect(result).toMatchObject({ - success: true, - limit: 2, + await expect(limiter.limit(key)).resolves.toMatchObject({ + success: false, + limit: 3, + remaining: 0, }) - expect(Date.now() - start).toBeGreaterThanOrEqual(500) }) - it('waits until enough quota is available for weighted requests', async () => { - const limiter = createTestingRateLimiter({ - maxRequests: 2, - window: 1000, - timeout: 4000, - }) - const key = 'blocking-weighted' - - await expect(limiter.limit(key)).resolves.toMatchObject({ - success: true, - remaining: 1, + describe('timeout', () => { + it('waits until the next window when capacity is unavailable', async () => { + const limiter = createTestingRateLimiter({ + maxRequests: 2, + window: 1000, + timeout: 4000, + }) + const key = 'blocking' + + await limiter.limit(key) + await limiter.limit(key) + + const start = Date.now() + const result = await limiter.limit(key) + + expect(result).toMatchObject({ + success: true, + limit: 2, + }) + expect(Date.now() - start).toBeGreaterThanOrEqual(500) }) - await sleep(100) - - await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ - success: true, - limit: 2, - remaining: 0, + it('waits until enough quota is available for weighted requests', async () => { + const limiter = createTestingRateLimiter({ + maxRequests: 2, + window: 1000, + timeout: 4000, + }) + const key = 'blocking-weighted' + + await expect(limiter.limit(key)).resolves.toMatchObject({ + success: true, + remaining: 1, + }) + + await sleep(100) + + await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ + success: true, + limit: 2, + remaining: 0, + }) }) - }) - it('returns denial when reset exceeds timeout', async () => { - const limiter = createTestingRateLimiter({ - maxRequests: 2, - window: 10_000, - timeout: 100, - }) - const key = 'blocking-timeout' + it('returns denial when reset exceeds timeout', async () => { + const limiter = createTestingRateLimiter({ + maxRequests: 2, + window: 10_000, + timeout: 100, + }) + const key = 'blocking-timeout' - await limiter.limit(key) + await limiter.limit(key) - await sleep(100) + await sleep(100) - await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ - success: false, - limit: 2, - remaining: 0, + await expect(limiter.limit(key, { weight: 2 })).resolves.toMatchObject({ + success: false, + limit: 2, + remaining: 0, + }) }) }) - }) -}) + }, +) it('prefix creates a limiter that appends the prefix and shares the client', async () => { const redis = { eval: vi.fn(async () => [1, 1000]) } - const limiter = new UpstashRateLimiter(redis as any, { maxRequests: 1, window: 1000, prefix: 'app:' }).prefix('tenant:') + const limiter = new UpstashRateLimiter(redis as any, { + maxRequests: 1, + window: 1000, + prefix: 'app:', + }).prefix('tenant:') await expect(limiter.limit('key')).resolves.toMatchObject({ success: true, remaining: 0 }) expect(redis.eval).toHaveBeenCalledWith(expect.any(String), ['app:tenant:key'], ['1', '1000']) diff --git a/packages/ratelimit/src/adapters/upstash.ts b/packages/ratelimit/src/adapters/upstash.ts index eb7103c029..1ea7bb5788 100644 --- a/packages/ratelimit/src/adapters/upstash.ts +++ b/packages/ratelimit/src/adapters/upstash.ts @@ -1,6 +1,7 @@ import type { Redis } from '@upstash/redis' -import type { BaseRedisRateLimiterOptions } from './base-redis' -import { BaseRedisRateLimiter } from './base-redis' + +import type { BaseRedisRateLimiterOptions } from './base-redis.ts' +import { BaseRedisRateLimiter } from './base-redis.ts' export interface UpstashRateLimiterOptions extends BaseRedisRateLimiterOptions {} @@ -24,7 +25,10 @@ export class UpstashRateLimiter extends BaseRedisRateLimiter { * @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters} */ prefix(prefix: string): UpstashRateLimiter { - return new UpstashRateLimiter(this.redis, { ...this.options, prefix: `${this.keyPrefix}${prefix}` }) + return new UpstashRateLimiter(this.redis, { + ...this.options, + prefix: `${this.keyPrefix}${prefix}`, + }) } protected async evalScript(script: string, keys: string[], args: string[]): Promise { diff --git a/packages/ratelimit/src/handler-plugin.test.ts b/packages/ratelimit/src/handler-plugin.test.ts index 504fad0918..3f7fed6902 100644 --- a/packages/ratelimit/src/handler-plugin.test.ts +++ b/packages/ratelimit/src/handler-plugin.test.ts @@ -1,15 +1,17 @@ import { ORPCError, os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' -import { RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL, RateLimitHandlerPlugin } from './handler-plugin' + +import { + RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL, + RateLimitHandlerPlugin, +} from './handler-plugin.ts' describe('ratelimitHandlerPlugin', () => { const handlerFn = vi.fn() const procedure = os.handler(handlerFn) const handler = new RPCHandler(procedure, { allowMethods: ['GET'], // tests below send GET requests - plugins: [ - new RateLimitHandlerPlugin(), - ], + plugins: [new RateLimitHandlerPlugin()], }) it('adds rate limit headers to a successful response after a limit check', async () => { diff --git a/packages/ratelimit/src/handler-plugin.ts b/packages/ratelimit/src/handler-plugin.ts index 3e1fd90540..7ef50fc175 100644 --- a/packages/ratelimit/src/handler-plugin.ts +++ b/packages/ratelimit/src/handler-plugin.ts @@ -1,17 +1,24 @@ import type { AnyProcedure, Context } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '@orpc/server/standard' -import type { StandardHeaders } from '@standard-server/core' -import type { RateLimitResult } from './types' +import type { + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, +} from '@orpc/server/standard' import { toArray } from '@orpc/shared' +import type { StandardHeaders } from '@standard-server/core' + +import type { RateLimitResult } from './types.ts' -export const RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for('ORPC_RATELIMIT_HANDLER_PLUGIN_CONTEXT') +export const RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for( + 'ORPC_RATELIMIT_HANDLER_PLUGIN_CONTEXT', +) export interface RateLimitHandlerPluginContext { [RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL]?: { /** * The result of the ratelimiter after applying limits */ - checks: { procedure: AnyProcedure, path: string[], result: RateLimitResult }[] + checks: { procedure: AnyProcedure; path: string[]; result: RateLimitResult }[] } } @@ -25,8 +32,12 @@ export class RateLimitHandlerPlugin implements StandardHandle name = '~ratelimit' init(options: StandardHandlerOptions): StandardHandlerOptions { + // oxlint-disable-next-line unicorn/consistent-function-scoping const routingInterceptor: StandardHandlerRoutingInterceptor = async (interceptorOptions) => { - const pluginContext: Exclude = { checks: [] } + const pluginContext: Exclude< + RateLimitHandlerPluginContext[typeof RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL], + undefined + > = { checks: [] } const result = await interceptorOptions.next({ ...interceptorOptions, @@ -37,12 +48,14 @@ export class RateLimitHandlerPlugin implements StandardHandle }) if (result.matched && pluginContext.checks.length) { - const exceededChecks = pluginContext.checks.filter(r => !r.result.success) + const exceededChecks = pluginContext.checks.filter((r) => !r.result.success) // Pick the most constrained result: prefer exceeded limits, fall back to all results. // Treat undefined remaining as 0 — unknown capacity is assumed fully exhausted. - const mostConstrained = (exceededChecks.length ? exceededChecks : pluginContext.checks).reduce( - (a, b) => (a.result.remaining ?? Infinity) <= (b.result.remaining ?? Infinity) ? a : b, + const mostConstrained = ( + exceededChecks.length ? exceededChecks : pluginContext.checks + ).reduce((a, b) => + (a.result.remaining ?? Infinity) <= (b.result.remaining ?? Infinity) ? a : b, ) const headers: StandardHeaders = { @@ -52,8 +65,15 @@ export class RateLimitHandlerPlugin implements StandardHandle 'ratelimit-reset': mostConstrained.result.reset?.toString(), } - if (result.response.status >= 400 && !mostConstrained.result.success && mostConstrained.result.reset !== undefined) { - headers['retry-after'] = Math.max(0, Math.ceil((mostConstrained.result.reset - Date.now()) / 1000)).toString() + if ( + result.response.status >= 400 && + !mostConstrained.result.success && + mostConstrained.result.reset !== undefined + ) { + headers['retry-after'] = Math.max( + 0, + Math.ceil((mostConstrained.result.reset - Date.now()) / 1000), + ).toString() } return { @@ -70,10 +90,7 @@ export class RateLimitHandlerPlugin implements StandardHandle return { ...options, - routingInterceptors: [ - ...toArray(options.routingInterceptors), - routingInterceptor, - ], + routingInterceptors: [...toArray(options.routingInterceptors), routingInterceptor], } } } diff --git a/packages/ratelimit/src/index.test.ts b/packages/ratelimit/src/index.test.ts index e9a07c420d..2d8ff88e40 100644 --- a/packages/ratelimit/src/index.test.ts +++ b/packages/ratelimit/src/index.test.ts @@ -1,5 +1,5 @@ it('exports plugin and middleware factory', async () => { - await expect(import('./index')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ RateLimitHandlerPlugin: expect.any(Function), ratelimit: expect.any(Function), }) diff --git a/packages/ratelimit/src/index.ts b/packages/ratelimit/src/index.ts index 3c5daeee88..33670782ee 100644 --- a/packages/ratelimit/src/index.ts +++ b/packages/ratelimit/src/index.ts @@ -1,3 +1,3 @@ -export * from './handler-plugin' -export * from './middleware' -export * from './types' +export * from './handler-plugin.ts' +export * from './middleware.ts' +export * from './types.ts' diff --git a/packages/ratelimit/src/middleware.test-d.ts b/packages/ratelimit/src/middleware.test-d.ts index ecfde9d7b6..737fb14078 100644 --- a/packages/ratelimit/src/middleware.test-d.ts +++ b/packages/ratelimit/src/middleware.test-d.ts @@ -1,11 +1,12 @@ -import type { RateLimiter } from './types' import { os, type } from '@orpc/server' -import { ratelimit } from './middleware' + +import { ratelimit } from './middleware.ts' +import type { RateLimiter } from './types.ts' describe('ratelimit', () => { it('can infer context & input & meta types', () => { const procedure = os - .$context<{ userId: string, rateLimiter: RateLimiter }>() + .$context<{ userId: string; rateLimiter: RateLimiter }>() .input(type<{ amount: number }>()) .use(({ next }) => { return next({ diff --git a/packages/ratelimit/src/middleware.test.ts b/packages/ratelimit/src/middleware.test.ts index e467357dbd..d51e214319 100644 --- a/packages/ratelimit/src/middleware.test.ts +++ b/packages/ratelimit/src/middleware.test.ts @@ -1,22 +1,26 @@ -import type { RateLimiter, RateLimitResult } from './types' import { call, os, type } from '@orpc/server' -import { RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' -import { ratelimit, RATELIMIT_MIDDLEWARE_CONTEXT_SYMBOL } from './middleware' + +import { RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin.ts' +import { ratelimit, RATELIMIT_MIDDLEWARE_CONTEXT_SYMBOL } from './middleware.ts' +import type { RateLimiter, RateLimitResult } from './types.ts' describe('ratelimit', () => { const createLimiter = (result: RateLimitResult): RateLimiter => ({ limit: vi.fn().mockResolvedValue(result), }) - const success: RateLimitResult = { success: true, limit: 10, remaining: 5, reset: Date.now() + 60000 } + const success: RateLimitResult = { + success: true, + limit: 10, + remaining: 5, + reset: Date.now() + 60000, + } it('applies rate limit successfully', async () => { const limiter = createLimiter(success) const mw = ratelimit({ limiter, key: 'key' }) const procedure = os.use(mw).handler(() => 'ok') - await expect( - call(procedure, undefined, { context: {} }), - ).resolves.toBe('ok') + await expect(call(procedure, undefined, { context: {} })).resolves.toBe('ok') expect(limiter.limit).toHaveBeenCalledWith('key', {}) }) @@ -26,11 +30,10 @@ describe('ratelimit', () => { const limiter = createLimiter({ success: false, limit: 10, remaining: 0, reset }) const procedure = os.use(ratelimit({ limiter, key: 'key' })).handler(() => 'ok') - await expect( - call(procedure, undefined, { context: {} }), - ) - .rejects - .toMatchObject({ code: 'TOO_MANY_REQUESTS', data: { limit: 10, remaining: 0, reset } }) + await expect(call(procedure, undefined, { context: {} })).rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + data: { limit: 10, remaining: 0, reset }, + }) expect(limiter.limit).toHaveBeenCalledWith('key', {}) }) @@ -40,9 +43,7 @@ describe('ratelimit', () => { const mw = ratelimit({ limiter, key: 'key', weight: 3 }) const procedure = os.use(mw).handler(() => 'ok') - await expect( - call(procedure, undefined, { context: {} }), - ).resolves.toBe('ok') + await expect(call(procedure, undefined, { context: {} })).resolves.toBe('ok') expect(limiter.limit).toHaveBeenCalledWith('key', { weight: 3 }) }) @@ -53,7 +54,10 @@ describe('ratelimit', () => { const keyFn = vi.fn().mockResolvedValueOnce('key') const weightFn = vi.fn().mockResolvedValueOnce(3) const mw = ratelimit({ limiter: limiterFn, key: keyFn, weight: weightFn }) - const procedure = os.input(type()).use(mw).handler(() => 'ok') + const procedure = os + .input(type()) + .use(mw) + .handler(() => 'ok') await expect( call(procedure, '__input__', { context: { __context__: true }, path: ['__path__'] }), @@ -95,14 +99,10 @@ describe('ratelimit', () => { const limiter = createLimiter(success) const mw = ratelimit({ limiter, key: 'k', dedupe: false }) const innerHandlerFn = vi.fn().mockReturnValue('in') - const inner = os - .use(mw) - .handler(innerHandlerFn) - const outer = os - .use(mw) - .handler(async ({ context }) => { - return `out:${await call(inner, undefined, { context })}:${await call(inner, undefined, { context })}` - }) + const inner = os.use(mw).handler(innerHandlerFn) + const outer = os.use(mw).handler(async ({ context }) => { + return `out:${await call(inner, undefined, { context })}:${await call(inner, undefined, { context })}` + }) await call(outer, undefined, { context: {} }) @@ -114,14 +114,26 @@ describe('ratelimit', () => { expect(context0).not.toBe(context1) expect(context0).toEqual(context1) - expect(context0).toEqual({ applied: [{ limiter, key: 'k' }, { limiter, key: 'k' }] }) + expect(context0).toEqual({ + applied: [ + { limiter, key: 'k' }, + { limiter, key: 'k' }, + ], + }) }) describe('dedupe', () => { it('deduplicates by default', async () => { const limiter = createLimiter(success) const mw = ratelimit({ limiter, key: 'k' }) - await call(os.use(mw).use(mw).handler(() => 'ok'), undefined, { context: {} }) + await call( + os + .use(mw) + .use(mw) + .handler(() => 'ok'), + undefined, + { context: {} }, + ) expect(limiter.limit).toHaveBeenCalledTimes(1) }) @@ -129,7 +141,10 @@ describe('ratelimit', () => { const limiter = createLimiter(success) const mw = ratelimit({ limiter, key: 'k', dedupe: false }) await call( - os.use(mw).use(mw).handler(() => 'ok'), + os + .use(mw) + .use(mw) + .handler(() => 'ok'), undefined, { context: {} }, ) @@ -153,9 +168,7 @@ describe('ratelimit', () => { it('dedupes in nested calls', async () => { const limiter = createLimiter(success) const mw = ratelimit({ limiter, key: 'k' }) - const inner = os - .use(mw) - .handler(() => 'in') + const inner = os.use(mw).handler(() => 'in') const outer = os .use(mw) .handler(async ({ context }) => `out:${await call(inner, undefined, { context })}`) @@ -167,8 +180,10 @@ describe('ratelimit', () => { it('respects per-instance dedupe', async () => { const limiter = createLimiter(success) await call( - os.use(ratelimit({ limiter, key: 'k', dedupe: true })) - .use(ratelimit({ limiter, key: 'k', dedupe: false })).handler(() => 'ok'), + os + .use(ratelimit({ limiter, key: 'k', dedupe: true })) + .use(ratelimit({ limiter, key: 'k', dedupe: false })) + .handler(() => 'ok'), undefined, { context: {} }, ) diff --git a/packages/ratelimit/src/middleware.ts b/packages/ratelimit/src/middleware.ts index 4fe2c2acdb..72cc833eb2 100644 --- a/packages/ratelimit/src/middleware.ts +++ b/packages/ratelimit/src/middleware.ts @@ -1,35 +1,41 @@ import type { Context, Middleware, MiddlewareOptions } from '@orpc/server' -import type { Promisable, Value } from '@orpc/shared' -import type { RateLimitHandlerPluginContext } from './handler-plugin' -import type { RateLimiter } from './types' import { ORPCError } from '@orpc/server' +import type { Promisable, Value } from '@orpc/shared' import { toArray, value } from '@orpc/shared' -import { RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' -export const RATELIMIT_MIDDLEWARE_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for('ORPC_RATELIMIT_MIDDLEWARE_CONTEXT') +import type { RateLimitHandlerPluginContext } from './handler-plugin.ts' +import { RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin.ts' +import type { RateLimiter } from './types.ts' + +export const RATELIMIT_MIDDLEWARE_CONTEXT_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for( + 'ORPC_RATELIMIT_MIDDLEWARE_CONTEXT', +) export interface RateLimitMiddlewareContext { [RATELIMIT_MIDDLEWARE_CONTEXT_SYMBOL]?: { /** * The applied limits in this request, mainly for deduplication purposes */ - applied: { limiter: RateLimiter, key: string }[] + applied: { limiter: RateLimiter; key: string }[] } } -export interface RateLimitMiddlewareOptions< - TInContext extends Context, - TInput, -> { +export interface RateLimitMiddlewareOptions { /** * The rule set to use for rate limiting */ - limiter: Value, [options: MiddlewareOptions>, input: TInput]> + limiter: Value< + Promisable, + [options: MiddlewareOptions>, input: TInput] + > /** * The key to identify the user/requester */ - key: Value, [options: MiddlewareOptions>, input: TInput]> + key: Value< + Promisable, + [options: MiddlewareOptions>, input: TInput] + > /** * The weight of the request. Determines how many tokens or quota @@ -37,7 +43,12 @@ export interface RateLimitMiddlewareOptions< * * @default 1 */ - weight?: Value, [options: MiddlewareOptions>, input: TInput]> | undefined + weight?: + | Value< + Promisable, + [options: MiddlewareOptions>, input: TInput] + > + | undefined /** * If your ratelimit middleware is used multiple times @@ -56,12 +67,16 @@ export interface RateLimitMiddlewareOptions< * * @see {@link https://orpc.dev/docs/helpers/ratelimit#ratelimit-middleware | Rate Limit Helpers - Ratelimit Middleware} */ -export function ratelimit< - TInContext extends Context, +export function ratelimit({ + dedupe = true, + ...options +}: RateLimitMiddlewareOptions): Middleware< + TInContext, + object, TInput, ->( - { dedupe = true, ...options }: RateLimitMiddlewareOptions, -): Middleware { + any, + object +> { return async function ratelimit(middlewareOptions, input) { const [limiter, key, weight] = await Promise.all([ value(options.limiter, middlewareOptions, input), @@ -69,16 +84,24 @@ export function ratelimit< value(options.weight, middlewareOptions, input), ]) - const middlewareContext = (middlewareOptions.context as RateLimitMiddlewareContext)[RATELIMIT_MIDDLEWARE_CONTEXT_SYMBOL] - if (dedupe && middlewareContext?.applied.some(l => l.key === key && l.limiter === limiter)) { + const middlewareContext = (middlewareOptions.context as RateLimitMiddlewareContext)[ + RATELIMIT_MIDDLEWARE_CONTEXT_SYMBOL + ] + if (dedupe && middlewareContext?.applied.some((l) => l.key === key && l.limiter === limiter)) { return middlewareOptions.next() } const result = await limiter.limit(key, { weight }) - const pluginContext = (middlewareOptions.context as RateLimitHandlerPluginContext)[RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL] + const pluginContext = (middlewareOptions.context as RateLimitHandlerPluginContext)[ + RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL + ] if (pluginContext) { - pluginContext.checks.push({ path: middlewareOptions.path, procedure: middlewareOptions.procedure, result }) + pluginContext.checks.push({ + path: middlewareOptions.path, + procedure: middlewareOptions.procedure, + result, + }) } if (!result.success) { @@ -95,10 +118,7 @@ export function ratelimit< context: { [RATELIMIT_MIDDLEWARE_CONTEXT_SYMBOL]: { ...middlewareContext, - applied: [ - ...toArray(middlewareContext?.applied), - { limiter, key }, - ], + applied: [...toArray(middlewareContext?.applied), { limiter, key }], }, } satisfies RateLimitMiddlewareContext, }) diff --git a/packages/ratelimit/src/utils.ts b/packages/ratelimit/src/utils.ts index fdffaf41b4..cc0b14d23a 100644 --- a/packages/ratelimit/src/utils.ts +++ b/packages/ratelimit/src/utils.ts @@ -1,7 +1,8 @@ import type { Promisable } from '@orpc/shared' -import type { RateLimitOptions, RateLimitResult } from './types' import { sleep } from '@orpc/shared' +import type { RateLimitOptions, RateLimitResult } from './types.ts' + /** * Reads the request weight, defaulting to 1. * diff --git a/packages/ratelimit/tests/e2e.test.ts b/packages/ratelimit/tests/e2e.test.ts index 39b480addf..9b9d35f023 100644 --- a/packages/ratelimit/tests/e2e.test.ts +++ b/packages/ratelimit/tests/e2e.test.ts @@ -1,9 +1,10 @@ -import type { RateLimiter } from '../src' import { os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { z } from 'zod' -import { ratelimit, RateLimitHandlerPlugin } from '../src' -import { MemoryRateLimiter } from '../src/adapters/memory' + +import { MemoryRateLimiter } from '../src/adapters/memory.ts' +import type { RateLimiter } from '../src/index.ts' +import { ratelimit, RateLimitHandlerPlugin } from '../src/index.ts' it('works', async () => { const router = { @@ -22,9 +23,7 @@ it('works', async () => { } const handler = new RPCHandler(router, { - plugins: [ - new RateLimitHandlerPlugin(), - ], + plugins: [new RateLimitHandlerPlugin()], }) const request = new Request('https://example.com/login', { diff --git a/packages/ratelimit/tsconfig.json b/packages/ratelimit/tsconfig.json index 7794b46fc2..9979a0715c 100644 --- a/packages/ratelimit/tsconfig.json +++ b/packages/ratelimit/tsconfig.json @@ -1,16 +1,3 @@ { - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../server" }, - { "path": "../shared" } - ], - "include": ["package.json", "src"], - "exclude": [ - "**/*.bench.*", - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] + "extends": "../../tsconfig.lib.json" } diff --git a/packages/server/README.md b/packages/server/README.md index e3254236d1..1dcae39bee 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -41,10 +41,11 @@ export const router = { Then call them from a client that knows every input and output, without generating anything: ```ts -import type { RouterClient } from '@orpc/server' -import type { router } from './router' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' + +import type { router } from './router' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) const client: RouterClient = createORPCClient(link) diff --git a/packages/server/build.config.ts b/packages/server/build.config.ts deleted file mode 100644 index 673bfcaf32..0000000000 --- a/packages/server/build.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineBuildConfig } from 'obuild/config' -import { isTestFile, verifyDist } from '../../scripts/build-utils' - -export default defineBuildConfig({ - entries: [ - { - type: 'transform', - input: './src', - outDir: './dist', - filter: file => !isTestFile(file), - }, - ], - hooks: { - end: verifyDist, - }, -}) diff --git a/packages/server/package.json b/packages/server/package.json index 8eb613e3a9..e17ddbff5f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,142 +1,129 @@ { "name": "@orpc/server", - "type": "module", "version": "2.0.0-beta.48", "description": "Build typesafe APIs with oRPC: RPC and OpenAPI support, middleware, and adapters for Node.js, Bun, Deno, Fetch, Fastify, WebSocket, AWS Lambda, and more", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "keywords": [ + "api", + "aws-lambda", + "bun", + "deno", + "fastify", + "middleware", + "nodejs", + "openapi", + "orpc", + "rpc", + "server", + "typesafe", + "typescript", + "websocket" ], "homepage": "https://orpc.dev", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/orpc.git", "directory": "packages/server" }, - "keywords": [ - "orpc", - "rpc", - "api", - "typescript", - "typesafe", - "server", - "openapi", - "middleware", - "nodejs", - "bun", - "deno", - "fastify", - "websocket", - "aws-lambda" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" ], + "type": "module", "sideEffects": [ - "./dist/extensions/callable.mjs" + "./dist/extensions/callable.js" ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./helpers": "./src/helpers/index.ts", + "./plugins": "./src/plugins/index.ts", + "./standard": "./src/adapters/standard/index.ts", + "./standard-peer": "./src/adapters/standard-peer/index.ts", + "./fetch": "./src/adapters/fetch/index.ts", + "./node": "./src/adapters/node/index.ts", + "./aws-lambda": "./src/adapters/aws-lambda/index.ts", + "./fastify": "./src/adapters/fastify/index.ts", + "./websocket": "./src/adapters/websocket/index.ts", + "./message-port": "./src/adapters/message-port/index.ts", + "./crossws": "./src/adapters/crossws/index.ts", + "./extensions/callable": "./src/extensions/callable.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./helpers": { - "types": "./dist/helpers/index.d.mts", - "import": "./dist/helpers/index.mjs", - "default": "./dist/helpers/index.mjs" + "types": "./dist/helpers/index.d.ts", + "import": "./dist/helpers/index.js", + "default": "./dist/helpers/index.js" }, "./plugins": { - "types": "./dist/plugins/index.d.mts", - "import": "./dist/plugins/index.mjs", - "default": "./dist/plugins/index.mjs" + "types": "./dist/plugins/index.d.ts", + "import": "./dist/plugins/index.js", + "default": "./dist/plugins/index.js" }, "./standard": { - "types": "./dist/adapters/standard/index.d.mts", - "import": "./dist/adapters/standard/index.mjs", - "default": "./dist/adapters/standard/index.mjs" + "types": "./dist/adapters/standard/index.d.ts", + "import": "./dist/adapters/standard/index.js", + "default": "./dist/adapters/standard/index.js" }, "./standard-peer": { - "types": "./dist/adapters/standard-peer/index.d.mts", - "import": "./dist/adapters/standard-peer/index.mjs", - "default": "./dist/adapters/standard-peer/index.mjs" + "types": "./dist/adapters/standard-peer/index.d.ts", + "import": "./dist/adapters/standard-peer/index.js", + "default": "./dist/adapters/standard-peer/index.js" }, "./fetch": { - "types": "./dist/adapters/fetch/index.d.mts", - "import": "./dist/adapters/fetch/index.mjs", - "default": "./dist/adapters/fetch/index.mjs" + "types": "./dist/adapters/fetch/index.d.ts", + "import": "./dist/adapters/fetch/index.js", + "default": "./dist/adapters/fetch/index.js" }, "./node": { - "types": "./dist/adapters/node/index.d.mts", - "import": "./dist/adapters/node/index.mjs", - "default": "./dist/adapters/node/index.mjs" + "types": "./dist/adapters/node/index.d.ts", + "import": "./dist/adapters/node/index.js", + "default": "./dist/adapters/node/index.js" }, "./aws-lambda": { - "types": "./dist/adapters/aws-lambda/index.d.mts", - "import": "./dist/adapters/aws-lambda/index.mjs", - "default": "./dist/adapters/aws-lambda/index.mjs" + "types": "./dist/adapters/aws-lambda/index.d.ts", + "import": "./dist/adapters/aws-lambda/index.js", + "default": "./dist/adapters/aws-lambda/index.js" }, "./fastify": { - "types": "./dist/adapters/fastify/index.d.mts", - "import": "./dist/adapters/fastify/index.mjs", - "default": "./dist/adapters/fastify/index.mjs" + "types": "./dist/adapters/fastify/index.d.ts", + "import": "./dist/adapters/fastify/index.js", + "default": "./dist/adapters/fastify/index.js" }, "./websocket": { - "types": "./dist/adapters/websocket/index.d.mts", - "import": "./dist/adapters/websocket/index.mjs", - "default": "./dist/adapters/websocket/index.mjs" + "types": "./dist/adapters/websocket/index.d.ts", + "import": "./dist/adapters/websocket/index.js", + "default": "./dist/adapters/websocket/index.js" }, "./message-port": { - "types": "./dist/adapters/message-port/index.d.mts", - "import": "./dist/adapters/message-port/index.mjs", - "default": "./dist/adapters/message-port/index.mjs" + "types": "./dist/adapters/message-port/index.d.ts", + "import": "./dist/adapters/message-port/index.js", + "default": "./dist/adapters/message-port/index.js" }, "./crossws": { - "types": "./dist/adapters/crossws/index.d.mts", - "import": "./dist/adapters/crossws/index.mjs", - "default": "./dist/adapters/crossws/index.mjs" + "types": "./dist/adapters/crossws/index.d.ts", + "import": "./dist/adapters/crossws/index.js", + "default": "./dist/adapters/crossws/index.js" }, "./extensions/callable": { - "types": "./dist/extensions/callable.d.mts", - "import": "./dist/extensions/callable.mjs", - "default": "./dist/extensions/callable.mjs" + "types": "./dist/extensions/callable.d.ts", + "import": "./dist/extensions/callable.js", + "default": "./dist/extensions/callable.js" } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts", - "./helpers": "./src/helpers/index.ts", - "./plugins": "./src/plugins/index.ts", - "./standard": "./src/adapters/standard/index.ts", - "./standard-peer": "./src/adapters/standard-peer/index.ts", - "./fetch": "./src/adapters/fetch/index.ts", - "./node": "./src/adapters/node/index.ts", - "./aws-lambda": "./src/adapters/aws-lambda/index.ts", - "./fastify": "./src/adapters/fastify/index.ts", - "./websocket": "./src/adapters/websocket/index.ts", - "./message-port": "./src/adapters/message-port/index.ts", - "./crossws": "./src/adapters/crossws/index.ts", - "./extensions/callable": "./src/extensions/callable.ts" - }, - "files": [ - "dist" - ], "scripts": { - "prepack": "obuild", - "type:check": "tsc -b" - }, - "peerDependencies": { - "crossws": ">=0.3.4", - "fastify": ">=5.6.1" - }, - "peerDependenciesMeta": { - "crossws": { - "optional": true - }, - "fastify": { - "optional": true - } + "prepack": "tsc" }, "dependencies": { "@orpc/client": "workspace:*", @@ -156,5 +143,17 @@ "supertest": "^7.3.1", "ws": "^8.22.1", "zod": "^4.6.7" + }, + "peerDependencies": { + "crossws": ">=0.3.4", + "fastify": ">=5.6.1" + }, + "peerDependenciesMeta": { + "crossws": { + "optional": true + }, + "fastify": { + "optional": true + } } } diff --git a/packages/server/src/adapters/aws-lambda/handler.ts b/packages/server/src/adapters/aws-lambda/handler.ts index 8422ce1fa5..82ae7166c8 100644 --- a/packages/server/src/adapters/aws-lambda/handler.ts +++ b/packages/server/src/adapters/aws-lambda/handler.ts @@ -1,10 +1,15 @@ import type { MaybeOptionalOptions } from '@orpc/shared' -import type { AnyAPIGatewayProxyEvent, HttpResponseStream, SendStandardResponseOptions } from '@standard-server/aws-lambda' -import type { Context } from '../../context' -import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard' import { resolveMaybeOptionalOptions } from '@orpc/shared' +import type { + AnyAPIGatewayProxyEvent, + HttpResponseStream, + SendStandardResponseOptions, +} from '@standard-server/aws-lambda' import { sendStandardResponse, toStandardLazyRequest } from '@standard-server/aws-lambda' -import { resolveFriendlyStandardHandlerHandleOptions } from '../standard' + +import type { Context } from '../../context.ts' +import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard/index.ts' +import { resolveFriendlyStandardHandlerHandleOptions } from '../standard/index.ts' export type AwsLambdaHandlerHandleResult = { matched: true } | { matched: false } diff --git a/packages/server/src/adapters/aws-lambda/index.test.ts b/packages/server/src/adapters/aws-lambda/index.test.ts index 1ef15634df..f009eb3a9e 100644 --- a/packages/server/src/adapters/aws-lambda/index.test.ts +++ b/packages/server/src/adapters/aws-lambda/index.test.ts @@ -1,5 +1,5 @@ it('exports RPCHandler', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ RPCHandler: expect.any(Function), }) }) diff --git a/packages/server/src/adapters/aws-lambda/index.ts b/packages/server/src/adapters/aws-lambda/index.ts index f5895fe7ec..2f76c30682 100644 --- a/packages/server/src/adapters/aws-lambda/index.ts +++ b/packages/server/src/adapters/aws-lambda/index.ts @@ -1,5 +1,5 @@ -export * from './handler' -export * from './rpc-handler' +export * from './handler.ts' +export * from './rpc-handler.ts' export type { AnyAPIGatewayProxyEvent, diff --git a/packages/server/src/adapters/aws-lambda/rpc-handler.test.ts b/packages/server/src/adapters/aws-lambda/rpc-handler.test.ts index 1887f31737..c2a9a2477c 100644 --- a/packages/server/src/adapters/aws-lambda/rpc-handler.test.ts +++ b/packages/server/src/adapters/aws-lambda/rpc-handler.test.ts @@ -1,13 +1,19 @@ -import type { APIGatewayProxyEventV2, AwsLambdaGlobal, HttpResponseStream } from '@standard-server/aws-lambda' -import type { StandardHandlerPlugin } from '../standard' import { Buffer } from 'node:buffer' import { Writable } from 'node:stream' -import { os } from '../../builder' -import { RPCHandler } from './rpc-handler' + +import type { + APIGatewayProxyEventV2, + AwsLambdaGlobal, + HttpResponseStream, +} from '@standard-server/aws-lambda' + +import { os } from '../../builder.ts' +import type { StandardHandlerPlugin } from '../standard/index.ts' +import { RPCHandler } from './rpc-handler.ts' class TestResponseStream extends Writable implements HttpResponseStream { chunks: Buffer[] = [] - metadata: { statusCode: number, headers: Record, cookies: string[] } | undefined + metadata: { statusCode: number; headers: Record; cookies: string[] } | undefined get text(): string { return Buffer.concat(this.chunks).toString() @@ -42,10 +48,10 @@ beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('awslambda', { - streamifyResponse: handler => handler, + streamifyResponse: (handler) => handler, HttpResponseStream: { from(responseStream, metadata) { - (responseStream as TestResponseStream).metadata = metadata + ;(responseStream as TestResponseStream).metadata = metadata return responseStream }, }, @@ -59,9 +65,7 @@ afterEach(() => { describe('rpcHandler', () => { it('accepts context and prefix options in handle method', async () => { const handler = new RPCHandler({ - ping: os - .$context<{ userId: string }>() - .handler(({ context }) => context.userId), + ping: os.$context<{ userId: string }>().handler(({ context }) => context.userId), }) const responseStream = new TestResponseStream() @@ -75,10 +79,14 @@ describe('rpcHandler', () => { expect(responseStream.text).toContain('u_123') const mismatchStream = new TestResponseStream() - const mismatchResult = await handler.handle(createEvent({ rawPath: '/invalid/ping' }), mismatchStream, { - context: { userId: 'u_123' }, - prefix: '/api/v1', - }) + const mismatchResult = await handler.handle( + createEvent({ rawPath: '/invalid/ping' }), + mismatchStream, + { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }, + ) expect(mismatchResult.matched).toBe(false) expect(mismatchStream.metadata).toBeUndefined() @@ -92,7 +100,10 @@ describe('rpcHandler', () => { return { ...options, routingInterceptors: [ - async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + async () => ({ + matched: true, + response: { status: 200, headers: {}, body: 'intercepted' }, + }), ], } }, @@ -101,7 +112,10 @@ describe('rpcHandler', () => { const handler = new RPCHandler({}, { plugins: [plugin] }) const responseStream = new TestResponseStream() - const result = await handler.handle(createEvent({ requestContext: { http: { method: 'GET' } }, body: undefined }), responseStream) + const result = await handler.handle( + createEvent({ requestContext: { http: { method: 'GET' } }, body: undefined }), + responseStream, + ) expect(result.matched).toBe(true) expect(responseStream.text).toBe('"intercepted"') @@ -113,11 +127,14 @@ describe('rpcHandler', () => { }) const responseStream = new TestResponseStream() - const result = await handler.handle(createEvent({ - requestContext: { http: { method: 'GET' } }, - rawQueryString: `data=${encodeURIComponent(JSON.stringify({ json: null }))}`, - body: undefined, - }), responseStream) + const result = await handler.handle( + createEvent({ + requestContext: { http: { method: 'GET' } }, + rawQueryString: `data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + body: undefined, + }), + responseStream, + ) expect(result.matched).toBe(false) }) @@ -133,11 +150,14 @@ describe('rpcHandler', () => { ) const responseStream = new TestResponseStream() - const result = await handler.handle(createEvent({ - requestContext: { http: { method: 'GET' } }, - rawQueryString: `data=${encodeURIComponent(JSON.stringify({ json: null }))}`, - body: undefined, - }), responseStream) + const result = await handler.handle( + createEvent({ + requestContext: { http: { method: 'GET' } }, + rawQueryString: `data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + body: undefined, + }), + responseStream, + ) expect(result.matched).toBe(true) expect(responseStream.metadata?.statusCode).toBe(200) diff --git a/packages/server/src/adapters/aws-lambda/rpc-handler.ts b/packages/server/src/adapters/aws-lambda/rpc-handler.ts index 634b51490c..4281f1ee54 100644 --- a/packages/server/src/adapters/aws-lambda/rpc-handler.ts +++ b/packages/server/src/adapters/aws-lambda/rpc-handler.ts @@ -1,9 +1,9 @@ -import type { Context } from '../../context' -import type { Router } from '../../router' -import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard' -import type { AwsLambdaHandlerOptions } from './handler' -import { RPCHandlerCodec, StandardHandler } from '../standard' -import { AwsLambdaHandler } from './handler' +import type { Context } from '../../context.ts' +import type { Router } from '../../router.ts' +import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard/index.ts' +import { RPCHandlerCodec, StandardHandler } from '../standard/index.ts' +import type { AwsLambdaHandlerOptions } from './handler.ts' +import { AwsLambdaHandler } from './handler.ts' export interface RPCHandlerOptions extends AwsLambdaHandlerOptions, StandardHandlerOptions, RPCHandlerCodecOptions {} @@ -18,10 +18,7 @@ export interface RPCHandlerOptions * @see {@link https://orpc.dev/docs/adapters/aws-lambda | AWS Lambda Adapter} */ export class RPCHandler extends AwsLambdaHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + constructor(router: Router, options: NoInfer> = {}) { const codec = new RPCHandlerCodec(router, options) const handler = new StandardHandler(codec, options) super(handler, options) diff --git a/packages/server/src/adapters/crossws/handler.ts b/packages/server/src/adapters/crossws/handler.ts index cd88ff3d13..a273abd435 100644 --- a/packages/server/src/adapters/crossws/handler.ts +++ b/packages/server/src/adapters/crossws/handler.ts @@ -1,11 +1,17 @@ import type { MaybeOptionalOptions } from '@orpc/shared' +import { resolveMaybeOptionalOptions } from '@orpc/shared' import type { DecodePeerMessageOptions, EncodePeerMessageOptions } from '@standard-server/peer' +import { + decodePeerMessage, + encodePeerMessage, + isClientPeerSendMessage, + ServerPeer, +} from '@standard-server/peer' import type { Message, Peer } from 'crossws' -import type { Context } from '../../context' -import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard' -import { resolveMaybeOptionalOptions } from '@orpc/shared' -import { decodePeerMessage, encodePeerMessage, isClientPeerSendMessage, ServerPeer } from '@standard-server/peer' -import { createStandardPeerRequestHandler } from '../standard-peer' + +import type { Context } from '../../context.ts' +import { createStandardPeerRequestHandler } from '../standard-peer/index.ts' +import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard/index.ts' export type CrosswsPeerLike = Pick export type CrosswsMessageLike = Pick @@ -55,7 +61,10 @@ export class experimental_CrosswsHandler { * Message order is important: loading -> decode -> .message. * This flow must stay synchronous, or we need to use `sequential` helper */ - const encodedMessage = typeof message.rawData === 'string' ? message.rawData : message.uint8Array() as Uint8Array + const encodedMessage = + typeof message.rawData === 'string' + ? message.rawData + : (message.uint8Array() as Uint8Array) const result = decodePeerMessage(encodedMessage, this.decodePeerMessageOptions) let peer = this.peers.get(ws) @@ -66,9 +75,12 @@ export class experimental_CrosswsHandler { } if (!peer) { - this.peers.set(ws, peer = new ServerPeer(async (message) => { - ws.send(await encodePeerMessage(message, this.encodePeerMessageOptions)) - })) + this.peers.set( + ws, + (peer = new ServerPeer(async (message) => { + ws.send(await encodePeerMessage(message, this.encodePeerMessageOptions)) + })), + ) } if (result.matched && isClientPeerSendMessage(result.message)) { diff --git a/packages/server/src/adapters/crossws/index.test.ts b/packages/server/src/adapters/crossws/index.test.ts index ddc4fabdf2..eff7140892 100644 --- a/packages/server/src/adapters/crossws/index.test.ts +++ b/packages/server/src/adapters/crossws/index.test.ts @@ -1,3 +1,3 @@ it('exports RPCHandler', async () => { - await expect(import('.')).resolves.toHaveProperty('experimental_RPCHandler') + await expect(import('./index.ts')).resolves.toHaveProperty('experimental_RPCHandler') }) diff --git a/packages/server/src/adapters/crossws/index.ts b/packages/server/src/adapters/crossws/index.ts index 97d3dd64c7..4e8b799a20 100644 --- a/packages/server/src/adapters/crossws/index.ts +++ b/packages/server/src/adapters/crossws/index.ts @@ -1,2 +1,2 @@ -export * from './handler' -export * from './rpc-handler' +export * from './handler.ts' +export * from './rpc-handler.ts' diff --git a/packages/server/src/adapters/crossws/rpc-handler.test.ts b/packages/server/src/adapters/crossws/rpc-handler.test.ts index f6f447655e..4f29888c5f 100644 --- a/packages/server/src/adapters/crossws/rpc-handler.test.ts +++ b/packages/server/src/adapters/crossws/rpc-handler.test.ts @@ -1,6 +1,7 @@ import { decodePeerMessage, encodePeerMessage } from '@standard-server/peer' -import { os } from '../../builder' -import { experimental_RPCHandler as RPCHandler } from './rpc-handler' + +import { os } from '../../builder.ts' +import { experimental_RPCHandler as RPCHandler } from './rpc-handler.ts' describe('rpcHandler', () => { beforeEach(() => { @@ -8,14 +9,17 @@ describe('rpcHandler', () => { }) const createHandler = (options: ConstructorParameters[1] = {}) => { - return new RPCHandler({ - ping: os.handler(async ({ signal }) => { - await new Promise(resolve => setTimeout(resolve, 10)) - signal?.throwIfAborted() - - return 'pong' - }), - }, options) + return new RPCHandler( + { + ping: os.handler(async ({ signal }) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + signal?.throwIfAborted() + + return 'pong' + }), + }, + options, + ) } const createPeer = () => ({ @@ -31,16 +35,21 @@ describe('rpcHandler', () => { url?: `/${string}` rawData?: string | Uint8Array } = {}) => { - const encoded = rawData ?? await encodePeerMessage({ - id: '19', - kind: 'request', - json: { - url, - body: { json: 'input' }, - headers: {}, - method: 'POST', - }, - }, prefix ? { prefix } : undefined) + const encoded = + rawData ?? + (await encodePeerMessage( + { + id: '19', + kind: 'request', + json: { + url, + body: { json: 'input' }, + headers: {}, + method: 'POST', + }, + }, + prefix ? { prefix } : undefined, + )) return { rawData: encoded, @@ -56,9 +65,7 @@ describe('rpcHandler', () => { it('accepts context and prefix option in message method', async () => { const handler = new RPCHandler({ - ping: os - .$context<{ userId: string }>() - .handler(({ context }) => context.userId), + ping: os.$context<{ userId: string }>().handler(({ context }) => context.userId), }) const peer = createPeer() @@ -82,13 +89,19 @@ describe('rpcHandler', () => { it.each([ ['string', () => createMessage()], - ['bytes', async () => { - const message = await createMessage() - return { - ...message, - rawData: typeof message.rawData === 'string' ? new TextEncoder().encode(message.rawData) : message.rawData, - } - }], + [ + 'bytes', + async () => { + const message = await createMessage() + return { + ...message, + rawData: + typeof message.rawData === 'string' + ? new TextEncoder().encode(message.rawData) + : message.rawData, + } + }, + ], ])('handles %s request', async (_type, createMessage) => { const handler = createHandler() const peer = createPeer() @@ -140,7 +153,9 @@ describe('rpcHandler', () => { expect(result.matched).toBe(true) expect(peer.send).toHaveBeenCalledTimes(1) - const decoded = decodePeerMessage((peer as any).send.mock.calls[0][0], { prefix: 'orpc:' }) as any + const decoded = decodePeerMessage((peer as any).send.mock.calls[0][0], { + prefix: 'orpc:', + }) as any expect(decoded.matched).toBe(true) expect(decoded.message.kind).toBe('response') @@ -196,7 +211,7 @@ describe('rpcHandler', () => { const peer = createPeer() await handler.close(peer as any) - const result = await handler.message(peer as any, await createMessage() as any) + const result = await handler.message(peer as any, (await createMessage()) as any) expect(result.matched).toBe(true) expect(procedure).not.toHaveBeenCalled() diff --git a/packages/server/src/adapters/crossws/rpc-handler.ts b/packages/server/src/adapters/crossws/rpc-handler.ts index 88743063a3..e23846a5d5 100644 --- a/packages/server/src/adapters/crossws/rpc-handler.ts +++ b/packages/server/src/adapters/crossws/rpc-handler.ts @@ -1,9 +1,9 @@ -import type { Context } from '../../context' -import type { Router } from '../../router' -import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard' -import type { experimental_CrosswsHandlerOptions as CrosswsHandlerOptions } from './handler' -import { RPCHandlerCodec, StandardHandler } from '../standard' -import { experimental_CrosswsHandler as CrosswsHandler } from './handler' +import type { Context } from '../../context.ts' +import type { Router } from '../../router.ts' +import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard/index.ts' +import { RPCHandlerCodec, StandardHandler } from '../standard/index.ts' +import type { experimental_CrosswsHandlerOptions as CrosswsHandlerOptions } from './handler.ts' +import { experimental_CrosswsHandler as CrosswsHandler } from './handler.ts' export interface experimental_RPCHandlerOptions extends StandardHandlerOptions, RPCHandlerCodecOptions, CrosswsHandlerOptions {} @@ -15,10 +15,7 @@ export interface experimental_RPCHandlerOptions * @see {@link https://orpc.dev/docs/adapters/websocket | WebSocket Adapters} */ export class experimental_RPCHandler extends CrosswsHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + constructor(router: Router, options: NoInfer> = {}) { const codec = new RPCHandlerCodec(router, options) const handler = new StandardHandler(codec, options) super(handler, options) diff --git a/packages/server/src/adapters/fastify/handler.ts b/packages/server/src/adapters/fastify/handler.ts index 107b47b9cd..733bb94041 100644 --- a/packages/server/src/adapters/fastify/handler.ts +++ b/packages/server/src/adapters/fastify/handler.ts @@ -1,10 +1,15 @@ import type { MaybeOptionalOptions } from '@orpc/shared' -import type { AnyFastifyReply, AnyFastifyRequest, SendStandardResponseOptions } from '@standard-server/fastify' -import type { Context } from '../../context' -import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard' import { resolveMaybeOptionalOptions } from '@orpc/shared' +import type { + AnyFastifyReply, + AnyFastifyRequest, + SendStandardResponseOptions, +} from '@standard-server/fastify' import { sendStandardResponse, toStandardLazyRequest } from '@standard-server/fastify' -import { resolveFriendlyStandardHandlerHandleOptions } from '../standard' + +import type { Context } from '../../context.ts' +import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard/index.ts' +import { resolveFriendlyStandardHandlerHandleOptions } from '../standard/index.ts' export type FastifyHandlerHandleResult = { matched: true } | { matched: false } diff --git a/packages/server/src/adapters/fastify/index.test.ts b/packages/server/src/adapters/fastify/index.test.ts index 1ef15634df..f009eb3a9e 100644 --- a/packages/server/src/adapters/fastify/index.test.ts +++ b/packages/server/src/adapters/fastify/index.test.ts @@ -1,5 +1,5 @@ it('exports RPCHandler', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ RPCHandler: expect.any(Function), }) }) diff --git a/packages/server/src/adapters/fastify/index.ts b/packages/server/src/adapters/fastify/index.ts index 97d3dd64c7..4e8b799a20 100644 --- a/packages/server/src/adapters/fastify/index.ts +++ b/packages/server/src/adapters/fastify/index.ts @@ -1,2 +1,2 @@ -export * from './handler' -export * from './rpc-handler' +export * from './handler.ts' +export * from './rpc-handler.ts' diff --git a/packages/server/src/adapters/fastify/rpc-handler.test.ts b/packages/server/src/adapters/fastify/rpc-handler.test.ts index dbd6dfbf27..6e7faccde0 100644 --- a/packages/server/src/adapters/fastify/rpc-handler.test.ts +++ b/packages/server/src/adapters/fastify/rpc-handler.test.ts @@ -1,9 +1,10 @@ import type { FastifyInstance } from 'fastify' -import type { StandardHandlerPlugin } from '../standard' import Fastify from 'fastify' import request from 'supertest' -import { os } from '../../builder' -import { RPCHandler } from './rpc-handler' + +import { os } from '../../builder.ts' +import type { StandardHandlerPlugin } from '../standard/index.ts' +import { RPCHandler } from './rpc-handler.ts' describe('rpcHandler', () => { let app: FastifyInstance @@ -19,9 +20,7 @@ describe('rpcHandler', () => { it('accepts context and prefix options in handle method', async () => { const handler = new RPCHandler({ - ping: os - .$context<{ userId: string }>() - .handler(({ context }) => context.userId), + ping: os.$context<{ userId: string }>().handler(({ context }) => context.userId), }) app.all('/*', async (req, reply) => { @@ -39,12 +38,18 @@ describe('rpcHandler', () => { await app.ready() - const res = await request(app.server).post('/api/v1/ping').set('content-type', 'application/json').send({ json: null }) + const res = await request(app.server) + .post('/api/v1/ping') + .set('content-type', 'application/json') + .send({ json: null }) expect(res.status).toBe(200) expect(res.text).toContain('u_123') - const mismatchRes = await request(app.server).post('/invalid/ping').set('content-type', 'application/json').send({ json: null }) + const mismatchRes = await request(app.server) + .post('/invalid/ping') + .set('content-type', 'application/json') + .send({ json: null }) expect(mismatchRes.status).toBe(404) expect(mismatchRes.text).toBe('not matched') @@ -57,7 +62,10 @@ describe('rpcHandler', () => { return { ...options, routingInterceptors: [ - async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + async () => ({ + matched: true, + response: { status: 200, headers: {}, body: 'intercepted' }, + }), ], } }, @@ -95,8 +103,9 @@ describe('rpcHandler', () => { await app.ready() - const res = await request(app.server) - .get(`/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) + const res = await request(app.server).get( + `/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + ) expect(res.status).toBe(404) expect(res.text).toBe('not matched') @@ -119,8 +128,9 @@ describe('rpcHandler', () => { await app.ready() - const res = await request(app.server) - .get(`/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) + const res = await request(app.server).get( + `/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + ) expect(res.status).toBe(200) expect(res.text).toContain('pong') diff --git a/packages/server/src/adapters/fastify/rpc-handler.ts b/packages/server/src/adapters/fastify/rpc-handler.ts index 7cfc5f65c8..7f468af452 100644 --- a/packages/server/src/adapters/fastify/rpc-handler.ts +++ b/packages/server/src/adapters/fastify/rpc-handler.ts @@ -1,9 +1,9 @@ -import type { Context } from '../../context' -import type { Router } from '../../router' -import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard' -import type { FastifyHandlerOptions } from './handler' -import { RPCHandlerCodec, StandardHandler } from '../standard' -import { FastifyHandler } from './handler' +import type { Context } from '../../context.ts' +import type { Router } from '../../router.ts' +import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard/index.ts' +import { RPCHandlerCodec, StandardHandler } from '../standard/index.ts' +import type { FastifyHandlerOptions } from './handler.ts' +import { FastifyHandler } from './handler.ts' export interface RPCHandlerOptions extends FastifyHandlerOptions, StandardHandlerOptions, RPCHandlerCodecOptions {} @@ -14,10 +14,7 @@ export interface RPCHandlerOptions * @see {@link https://orpc.dev/docs/adapters/fastify | Fastify Adapter} */ export class RPCHandler extends FastifyHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + constructor(router: Router, options: NoInfer> = {}) { const codec = new RPCHandlerCodec(router, options) const handler = new StandardHandler(codec, options) super(handler, options) diff --git a/packages/server/src/adapters/fetch/handler.ts b/packages/server/src/adapters/fetch/handler.ts index 3d0dda6349..92276445f1 100644 --- a/packages/server/src/adapters/fetch/handler.ts +++ b/packages/server/src/adapters/fetch/handler.ts @@ -1,12 +1,15 @@ import type { MaybeOptionalOptions } from '@orpc/shared' -import type { ToFetchResponseOptions } from '@standard-server/fetch' -import type { Context } from '../../context' -import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard' import { resolveMaybeOptionalOptions } from '@orpc/shared' +import type { ToFetchResponseOptions } from '@standard-server/fetch' import { toFetchResponse, toStandardLazyRequest } from '@standard-server/fetch' -import { resolveFriendlyStandardHandlerHandleOptions } from '../standard' -export type FetchHandlerHandleResult = { matched: true, response: Response } | { matched: false, response?: undefined } +import type { Context } from '../../context.ts' +import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard/index.ts' +import { resolveFriendlyStandardHandlerHandleOptions } from '../standard/index.ts' + +export type FetchHandlerHandleResult = + | { matched: true; response: Response } + | { matched: false; response?: undefined } export interface FetchHandlerOptions<_T extends Context> { /** diff --git a/packages/server/src/adapters/fetch/index.test.ts b/packages/server/src/adapters/fetch/index.test.ts index 1ef15634df..f009eb3a9e 100644 --- a/packages/server/src/adapters/fetch/index.test.ts +++ b/packages/server/src/adapters/fetch/index.test.ts @@ -1,5 +1,5 @@ it('exports RPCHandler', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ RPCHandler: expect.any(Function), }) }) diff --git a/packages/server/src/adapters/fetch/index.ts b/packages/server/src/adapters/fetch/index.ts index b1f5dbc80f..ec5a563795 100644 --- a/packages/server/src/adapters/fetch/index.ts +++ b/packages/server/src/adapters/fetch/index.ts @@ -3,6 +3,6 @@ export { * @deprecated Use `RequestLimitHandlerPlugin` from `@orpc/server/plugins` instead. */ RequestLimitHandlerPlugin as BodyLimitPlugin, -} from '../../plugins/request-limit' -export * from './handler' -export * from './rpc-handler' +} from '../../plugins/request-limit.ts' +export * from './handler.ts' +export * from './rpc-handler.ts' diff --git a/packages/server/src/adapters/fetch/rpc-handler.test.ts b/packages/server/src/adapters/fetch/rpc-handler.test.ts index 437319e86c..304c014fe6 100644 --- a/packages/server/src/adapters/fetch/rpc-handler.test.ts +++ b/packages/server/src/adapters/fetch/rpc-handler.test.ts @@ -1,6 +1,6 @@ -import type { StandardHandlerPlugin } from '../standard' -import { os } from '../../builder' -import { RPCHandler } from './rpc-handler' +import { os } from '../../builder.ts' +import type { StandardHandlerPlugin } from '../standard/index.ts' +import { RPCHandler } from './rpc-handler.ts' describe('rpcHandler', () => { beforeEach(() => { @@ -9,9 +9,7 @@ describe('rpcHandler', () => { it('accepts context and prefix options in handle method', async () => { const contextHandler = new RPCHandler({ - ping: os - .$context<{ userId: string }>() - .handler(({ context }) => context.userId), + ping: os.$context<{ userId: string }>().handler(({ context }) => context.userId), }) const { matched, response } = await contextHandler.handle( @@ -53,7 +51,10 @@ describe('rpcHandler', () => { return { ...options, routingInterceptors: [ - async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + async () => ({ + matched: true, + response: { status: 200, headers: {}, body: 'intercepted' }, + }), ], } }, @@ -73,11 +74,13 @@ describe('rpcHandler', () => { const middleware = vi.fn(({ next }) => next()) const handler = new RPCHandler({ ping: os.use(middleware).handler(() => 'pong') }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ json: '9'.repeat(1_000_000), meta: [['bigint']] }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ json: '9'.repeat(1_000_000), meta: [['bigint']] }), + }), + ) expect(response?.status).toBe(400) expect(middleware).not.toHaveBeenCalled() @@ -89,7 +92,9 @@ describe('rpcHandler', () => { }) const result = await handler.handle( - new Request(`https://example.com/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`), + new Request( + `https://example.com/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + ), ) expect(result.matched).toBe(false) @@ -125,7 +130,9 @@ describe('rpcHandler', () => { ) const result = await handler.handle( - new Request(`https://example.com/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`), + new Request( + `https://example.com/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + ), ) expect(result.matched).toBe(true) @@ -134,7 +141,10 @@ describe('rpcHandler', () => { }) it('supports deciding allowMethods per procedure', async () => { - const allowMethods = vi.fn((method: string, _procedure: unknown, path: string[]) => method === 'GET' && path[0] === 'public') + const allowMethods = vi.fn( + (method: string, _procedure: unknown, path: string[]) => + method === 'GET' && path[0] === 'public', + ) const handler = new RPCHandler( { @@ -147,7 +157,9 @@ describe('rpcHandler', () => { ) const allowed = await handler.handle( - new Request(`https://example.com/public?data=${encodeURIComponent(JSON.stringify({ json: null }))}`), + new Request( + `https://example.com/public?data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + ), ) expect(allowed.matched).toBe(true) @@ -155,7 +167,9 @@ describe('rpcHandler', () => { await expect(allowed.response!.text()).resolves.toContain('public') const blocked = await handler.handle( - new Request(`https://example.com/private?data=${encodeURIComponent(JSON.stringify({ json: null }))}`), + new Request( + `https://example.com/private?data=${encodeURIComponent(JSON.stringify({ json: null }))}`, + ), ) expect(blocked.matched).toBe(false) @@ -173,7 +187,10 @@ describe('rpcHandler lazy router prefix scan', () => { const url = `http://localhost/rpc/${'/'.repeat(16_000)}%25` // warm up so JIT compilation does not count toward the budget - await handler.handle(new Request(`http://localhost/rpc/${'/'.repeat(100)}%25`, { method: 'POST' }), { prefix: '/rpc' }) + await handler.handle( + new Request(`http://localhost/rpc/${'/'.repeat(100)}%25`, { method: 'POST' }), + { prefix: '/rpc' }, + ) const start = performance.now() const result = await handler.handle(new Request(url, { method: 'POST' }), { prefix: '/rpc' }) @@ -184,7 +201,10 @@ describe('rpcHandler lazy router prefix scan', () => { expect(elapsed).toBeLessThan(100) // ~470ms when unbounded, a few ms once bounded // lazy routing still works - const ok = await handler.handle(new Request('http://localhost/rpc/planet/find', { method: 'POST' }), { prefix: '/rpc' }) + const ok = await handler.handle( + new Request('http://localhost/rpc/planet/find', { method: 'POST' }), + { prefix: '/rpc' }, + ) expect(ok.matched).toBe(true) expect(loader).toHaveBeenCalledTimes(1) }) diff --git a/packages/server/src/adapters/fetch/rpc-handler.ts b/packages/server/src/adapters/fetch/rpc-handler.ts index 2e3e777654..8ef7518899 100644 --- a/packages/server/src/adapters/fetch/rpc-handler.ts +++ b/packages/server/src/adapters/fetch/rpc-handler.ts @@ -1,9 +1,9 @@ -import type { Context } from '../../context' -import type { Router } from '../../router' -import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard' -import type { FetchHandlerOptions } from './handler' -import { RPCHandlerCodec, StandardHandler } from '../standard' -import { FetchHandler } from './handler' +import type { Context } from '../../context.ts' +import type { Router } from '../../router.ts' +import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard/index.ts' +import { RPCHandlerCodec, StandardHandler } from '../standard/index.ts' +import type { FetchHandlerOptions } from './handler.ts' +import { FetchHandler } from './handler.ts' export interface RPCHandlerOptions extends FetchHandlerOptions, StandardHandlerOptions, RPCHandlerCodecOptions {} @@ -16,10 +16,7 @@ export interface RPCHandlerOptions * @see {@link https://orpc.dev/docs/adapters/fetch-api | Fetch API Adapter} */ export class RPCHandler extends FetchHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + constructor(router: Router, options: NoInfer> = {}) { const codec = new RPCHandlerCodec(router, options) const handler = new StandardHandler(codec, options) super(handler, options) diff --git a/packages/server/src/adapters/message-port/handler.ts b/packages/server/src/adapters/message-port/handler.ts index 23889fffbb..edde5dcda0 100644 --- a/packages/server/src/adapters/message-port/handler.ts +++ b/packages/server/src/adapters/message-port/handler.ts @@ -1,14 +1,31 @@ import type { SupportedMessagePort } from '@orpc/client/message-port' +import { + onMessagePortClose, + onMessagePortMessage, + postMessagePortMessage, +} from '@orpc/client/message-port' import type { MaybeOptionalOptions, Promisable, Value } from '@orpc/shared' -import type { ClientPeerSendMessage, DecodePeerMessageOptions, EncodePeerMessageOptions } from '@standard-server/peer' -import type { Context } from '../../context' -import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard' -import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from '@orpc/client/message-port' import { resolveMaybeOptionalOptions, value } from '@orpc/shared' -import { decodePeerMessage, encodePeerMessage, isClientPeerSendMessage, isPeerMessage, ServerPeer } from '@standard-server/peer' -import { createStandardPeerRequestHandler } from '../standard-peer' - -type DecodedResponseMessage = ConstructorParameters[0] extends (message: infer TMessage) => unknown +import type { + ClientPeerSendMessage, + DecodePeerMessageOptions, + EncodePeerMessageOptions, +} from '@standard-server/peer' +import { + decodePeerMessage, + encodePeerMessage, + isClientPeerSendMessage, + isPeerMessage, + ServerPeer, +} from '@standard-server/peer' + +import type { Context } from '../../context.ts' +import { createStandardPeerRequestHandler } from '../standard-peer/index.ts' +import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard/index.ts' + +type DecodedResponseMessage = ConstructorParameters[0] extends ( + message: infer TMessage, +) => unknown ? TMessage : never @@ -23,7 +40,12 @@ export interface MessagePortHandlerOptions<_T extends Context> { * **Note**: Returning `null` or `undefined` disables this feature. * **Warning**: Ensure your message port implementation supports `transferable` objects before enabling this. */ - experimental_transfer?: Value, [message: DecodedResponseMessage, port: SupportedMessagePort]> | undefined + experimental_transfer?: + | Value< + Promisable, + [message: DecodedResponseMessage, port: SupportedMessagePort] + > + | undefined /** * Options for encoding peer messages. such as `prefix` for distinguishing messages on the same channel.. @@ -67,7 +89,7 @@ export class MessagePortHandler { * Message order is important: loading -> decode -> .message. * This flow must stay synchronous, or we need to use `sequential` helper */ - onMessagePortMessage(port, message => this.message(port, message, ...rest)) + onMessagePortMessage(port, (message) => this.message(port, message, ...rest)) onMessagePortClose(port, () => this.close(port)) } @@ -85,13 +107,14 @@ export class MessagePortHandler { if (typeof data === 'string' || data instanceof Uint8Array) { // MessagePort receives the exact payload sent, and `encodePeerMessage` only returns string or Uint8Array. - const result = decodePeerMessage(data as string | Uint8Array, this.decodePeerMessageOptions) + const result = decodePeerMessage( + data as string | Uint8Array, + this.decodePeerMessageOptions, + ) if (result.matched && isClientPeerSendMessage(result.message)) { peerMessage = result.message } - } - - else if (isPeerMessage(data) && isClientPeerSendMessage(data)) { + } else if (isPeerMessage(data) && isClientPeerSendMessage(data)) { peerMessage = data } @@ -106,23 +129,31 @@ export class MessagePortHandler { } if (!peer) { - this.peers.set(port, peer = new ServerPeer(async (message) => { - const transfer = await value(this.transfer, message, port) - - if (transfer) { - postMessagePortMessage(port, message, transfer) - } - else { - postMessagePortMessage(port, await encodePeerMessage(message, this.encodePeerMessageOptions)) - } - })) + this.peers.set( + port, + (peer = new ServerPeer(async (message) => { + const transfer = await value(this.transfer, message, port) + + if (transfer) { + postMessagePortMessage(port, message, transfer) + } else { + postMessagePortMessage( + port, + await encodePeerMessage(message, this.encodePeerMessageOptions), + ) + } + })), + ) } /** * Message order is important: loading -> decode -> .message. * This flow must stay synchronous, or we need to use `sequential` helper */ - await peer.message(peerMessage, createStandardPeerRequestHandler(this.handler, resolveMaybeOptionalOptions(rest))) + await peer.message( + peerMessage, + createStandardPeerRequestHandler(this.handler, resolveMaybeOptionalOptions(rest)), + ) return { matched: true } } diff --git a/packages/server/src/adapters/message-port/index.test.ts b/packages/server/src/adapters/message-port/index.test.ts index f77e52dcdc..b010072ddc 100644 --- a/packages/server/src/adapters/message-port/index.test.ts +++ b/packages/server/src/adapters/message-port/index.test.ts @@ -1,3 +1,3 @@ it('exports RPCHandler', async () => { - await expect(import('.')).resolves.toHaveProperty('RPCHandler') + await expect(import('./index.ts')).resolves.toHaveProperty('RPCHandler') }) diff --git a/packages/server/src/adapters/message-port/index.ts b/packages/server/src/adapters/message-port/index.ts index 97d3dd64c7..4e8b799a20 100644 --- a/packages/server/src/adapters/message-port/index.ts +++ b/packages/server/src/adapters/message-port/index.ts @@ -1,2 +1,2 @@ -export * from './handler' -export * from './rpc-handler' +export * from './handler.ts' +export * from './rpc-handler.ts' diff --git a/packages/server/src/adapters/message-port/rpc-handler.test.ts b/packages/server/src/adapters/message-port/rpc-handler.test.ts index 0604bb21a6..165ddf8deb 100644 --- a/packages/server/src/adapters/message-port/rpc-handler.test.ts +++ b/packages/server/src/adapters/message-port/rpc-handler.test.ts @@ -1,6 +1,7 @@ import { decodePeerMessage, encodePeerMessage } from '@standard-server/peer' -import { os } from '../../builder' -import { RPCHandler } from './rpc-handler' + +import { os } from '../../builder.ts' +import { RPCHandler } from './rpc-handler.ts' describe('rpcHandler', () => { beforeEach(() => { @@ -8,14 +9,17 @@ describe('rpcHandler', () => { }) const createHandler = (options: ConstructorParameters[1] = {}) => { - return new RPCHandler({ - ping: os.handler(async ({ signal }) => { - await new Promise(resolve => setTimeout(resolve, 10)) - signal?.throwIfAborted() - - return 'pong' - }), - }, options) + return new RPCHandler( + { + ping: os.handler(async ({ signal }) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + signal?.throwIfAborted() + + return 'pong' + }), + }, + options, + ) } const createPort = () => { @@ -32,24 +36,25 @@ describe('rpcHandler', () => { const createRequestMessage = async ({ prefix, url = '/ping', - }: { prefix?: string, url?: `/${string}` } = {}) => { - return encodePeerMessage({ - id: '19', - kind: 'request', - json: { - url, - body: { json: 'input' }, - headers: {}, - method: 'POST', + }: { prefix?: string; url?: `/${string}` } = {}) => { + return encodePeerMessage( + { + id: '19', + kind: 'request', + json: { + url, + body: { json: 'input' }, + headers: {}, + method: 'POST', + }, }, - }, prefix ? { prefix } : undefined) + prefix ? { prefix } : undefined, + ) } it('accepts context and prefix option in message method', async () => { const handler = new RPCHandler({ - ping: os - .$context<{ userId: string }>() - .handler(({ context }) => context.userId), + ping: os.$context<{ userId: string }>().handler(({ context }) => context.userId), }) const { serverPort } = createPort() @@ -78,10 +83,13 @@ describe('rpcHandler', () => { it.each([ ['string', () => createRequestMessage()], - ['bytes', async () => { - const message = await createRequestMessage() - return new TextEncoder().encode(message as string) - }], + [ + 'bytes', + async () => { + const message = await createRequestMessage() + return new TextEncoder().encode(message as string) + }, + ], ])('handles %s request', async (_type, createMessage) => { const handler = createHandler() const { serverPort } = createPort() @@ -275,7 +283,9 @@ describe('rpcHandler', () => { expect(result.matched).toBe(true) expect(transfer).toHaveBeenCalledTimes(1) expect(postMessage).toHaveBeenCalledTimes(1) - expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ kind: 'response' }), [transferable]) + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ kind: 'response' }), [ + transferable, + ]) }) it('wires message and close events via upgrade', async () => { diff --git a/packages/server/src/adapters/message-port/rpc-handler.ts b/packages/server/src/adapters/message-port/rpc-handler.ts index e191bac2c4..158443a610 100644 --- a/packages/server/src/adapters/message-port/rpc-handler.ts +++ b/packages/server/src/adapters/message-port/rpc-handler.ts @@ -1,9 +1,9 @@ -import type { Context } from '../../context' -import type { Router } from '../../router' -import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard' -import type { MessagePortHandlerOptions } from './handler' -import { RPCHandlerCodec, StandardHandler } from '../standard' -import { MessagePortHandler } from './handler' +import type { Context } from '../../context.ts' +import type { Router } from '../../router.ts' +import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard/index.ts' +import { RPCHandlerCodec, StandardHandler } from '../standard/index.ts' +import type { MessagePortHandlerOptions } from './handler.ts' +import { MessagePortHandler } from './handler.ts' export interface RPCHandlerOptions extends StandardHandlerOptions, RPCHandlerCodecOptions, MessagePortHandlerOptions {} @@ -15,10 +15,7 @@ export interface RPCHandlerOptions * @see {@link https://orpc.dev/docs/adapters/message-port | Message Port Adapter} */ export class RPCHandler extends MessagePortHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + constructor(router: Router, options: NoInfer> = {}) { const codec = new RPCHandlerCodec(router, options) const handler = new StandardHandler(codec, options) super(handler, options) diff --git a/packages/server/src/adapters/node/handler.ts b/packages/server/src/adapters/node/handler.ts index 7e3f1737b8..45e1d90e94 100644 --- a/packages/server/src/adapters/node/handler.ts +++ b/packages/server/src/adapters/node/handler.ts @@ -1,10 +1,15 @@ import type { MaybeOptionalOptions } from '@orpc/shared' -import type { NodeHttpRequest, NodeHttpResponse, SendStandardResponseOptions } from '@standard-server/node' -import type { Context } from '../../context' -import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard' import { resolveMaybeOptionalOptions } from '@orpc/shared' +import type { + NodeHttpRequest, + NodeHttpResponse, + SendStandardResponseOptions, +} from '@standard-server/node' import { sendStandardResponse, toStandardLazyRequest } from '@standard-server/node' -import { resolveFriendlyStandardHandlerHandleOptions } from '../standard' + +import type { Context } from '../../context.ts' +import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard/index.ts' +import { resolveFriendlyStandardHandlerHandleOptions } from '../standard/index.ts' export type NodeHttpHandlerHandleResult = { matched: true } | { matched: false } diff --git a/packages/server/src/adapters/node/index.test.ts b/packages/server/src/adapters/node/index.test.ts index 1ef15634df..f009eb3a9e 100644 --- a/packages/server/src/adapters/node/index.test.ts +++ b/packages/server/src/adapters/node/index.test.ts @@ -1,5 +1,5 @@ it('exports RPCHandler', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ RPCHandler: expect.any(Function), }) }) diff --git a/packages/server/src/adapters/node/index.ts b/packages/server/src/adapters/node/index.ts index b1f5dbc80f..ec5a563795 100644 --- a/packages/server/src/adapters/node/index.ts +++ b/packages/server/src/adapters/node/index.ts @@ -3,6 +3,6 @@ export { * @deprecated Use `RequestLimitHandlerPlugin` from `@orpc/server/plugins` instead. */ RequestLimitHandlerPlugin as BodyLimitPlugin, -} from '../../plugins/request-limit' -export * from './handler' -export * from './rpc-handler' +} from '../../plugins/request-limit.ts' +export * from './handler.ts' +export * from './rpc-handler.ts' diff --git a/packages/server/src/adapters/node/rpc-handler.test.ts b/packages/server/src/adapters/node/rpc-handler.test.ts index 4a85921585..b349d6426f 100644 --- a/packages/server/src/adapters/node/rpc-handler.test.ts +++ b/packages/server/src/adapters/node/rpc-handler.test.ts @@ -1,8 +1,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -import type { StandardHandlerPlugin } from '../standard' + import request from 'supertest' -import { os } from '../../builder' -import { RPCHandler } from './rpc-handler' + +import { os } from '../../builder.ts' +import type { StandardHandlerPlugin } from '../standard/index.ts' +import { RPCHandler } from './rpc-handler.ts' describe('rpcHandler', () => { beforeEach(() => { @@ -11,9 +13,7 @@ describe('rpcHandler', () => { it('accepts context and prefix options in handle method', async () => { const handler = new RPCHandler({ - ping: os - .$context<{ userId: string }>() - .handler(({ context }) => context.userId), + ping: os.$context<{ userId: string }>().handler(({ context }) => context.userId), }) const res = await request(async (req: IncomingMessage, response: ServerResponse) => { @@ -21,7 +21,10 @@ describe('rpcHandler', () => { context: { userId: 'u_123' }, prefix: '/api/v1', }) - }).post('/api/v1/ping').set('content-type', 'application/json').send({ json: null }) + }) + .post('/api/v1/ping') + .set('content-type', 'application/json') + .send({ json: null }) expect(res.status).toBe(200) expect(res.text).toContain('u_123') @@ -36,7 +39,10 @@ describe('rpcHandler', () => { response.statusCode = 404 response.end('not matched') } - }).post('/invalid/ping').set('content-type', 'application/json').send({ json: null }) + }) + .post('/invalid/ping') + .set('content-type', 'application/json') + .send({ json: null }) expect(mismatchRes.status).toBe(404) expect(mismatchRes.text).toBe('not matched') @@ -49,7 +55,10 @@ describe('rpcHandler', () => { return { ...options, routingInterceptors: [ - async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + async () => ({ + matched: true, + response: { status: 200, headers: {}, body: 'intercepted' }, + }), ], } }, @@ -77,8 +86,7 @@ describe('rpcHandler', () => { response.statusCode = 404 response.end('not matched') } - }) - .get(`/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) + }).get(`/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) expect(res.status).toBe(404) expect(res.text).toBe('not matched') @@ -96,8 +104,7 @@ describe('rpcHandler', () => { const res = await request(async (req: IncomingMessage, response: ServerResponse) => { await handler.handle(req as any, response as any) - }) - .get(`/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) + }).get(`/ping?data=${encodeURIComponent(JSON.stringify({ json: null }))}`) expect(res.status).toBe(200) expect(res.text).toContain('pong') diff --git a/packages/server/src/adapters/node/rpc-handler.ts b/packages/server/src/adapters/node/rpc-handler.ts index 53d79b8705..456f160d63 100644 --- a/packages/server/src/adapters/node/rpc-handler.ts +++ b/packages/server/src/adapters/node/rpc-handler.ts @@ -1,9 +1,9 @@ -import type { Context } from '../../context' -import type { Router } from '../../router' -import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard' -import type { NodeHttpHandlerOptions } from './handler' -import { RPCHandlerCodec, StandardHandler } from '../standard' -import { NodeHttpHandler } from './handler' +import type { Context } from '../../context.ts' +import type { Router } from '../../router.ts' +import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard/index.ts' +import { RPCHandlerCodec, StandardHandler } from '../standard/index.ts' +import type { NodeHttpHandlerOptions } from './handler.ts' +import { NodeHttpHandler } from './handler.ts' export interface RPCHandlerOptions extends NodeHttpHandlerOptions, StandardHandlerOptions, RPCHandlerCodecOptions {} @@ -15,10 +15,7 @@ export interface RPCHandlerOptions * @see {@link https://orpc.dev/docs/adapters/node-http | Node HTTP Adapter} */ export class RPCHandler extends NodeHttpHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + constructor(router: Router, options: NoInfer> = {}) { const codec = new RPCHandlerCodec(router, options) const handler = new StandardHandler(codec, options) super(handler, options) diff --git a/packages/server/src/adapters/standard-peer/index.test.ts b/packages/server/src/adapters/standard-peer/index.test.ts index d0c7464a73..8de52156f7 100644 --- a/packages/server/src/adapters/standard-peer/index.test.ts +++ b/packages/server/src/adapters/standard-peer/index.test.ts @@ -1,3 +1,3 @@ it('exports createStandardPeerRequestHandler', async () => { - await expect(import('.')).resolves.toHaveProperty('createStandardPeerRequestHandler') + await expect(import('./index.ts')).resolves.toHaveProperty('createStandardPeerRequestHandler') }) diff --git a/packages/server/src/adapters/standard-peer/index.ts b/packages/server/src/adapters/standard-peer/index.ts index 9c56149efa..5522418ddc 100644 --- a/packages/server/src/adapters/standard-peer/index.ts +++ b/packages/server/src/adapters/standard-peer/index.ts @@ -1 +1 @@ -export * from './utils' +export * from './utils.ts' diff --git a/packages/server/src/adapters/standard-peer/utils.test.ts b/packages/server/src/adapters/standard-peer/utils.test.ts index d2de8a9aa3..8d10432e12 100644 --- a/packages/server/src/adapters/standard-peer/utils.test.ts +++ b/packages/server/src/adapters/standard-peer/utils.test.ts @@ -1,5 +1,6 @@ import type { StandardLazyRequest } from '@standard-server/core' -import { createStandardPeerRequestHandler } from './utils' + +import { createStandardPeerRequestHandler } from './utils.ts' beforeEach(() => { vi.clearAllMocks() @@ -13,7 +14,10 @@ describe('createStandardPeerRequestHandler', () => { handle: vi.fn(async () => ({ matched: true, response })), } as any - const handleRequest = createStandardPeerRequestHandler(handler, { context: { context: true }, prefix: '/api' }) + const handleRequest = createStandardPeerRequestHandler(handler, { + context: { context: true }, + prefix: '/api', + }) const request: StandardLazyRequest = { resolveBody: async () => 'request-body', @@ -28,7 +32,10 @@ describe('createStandardPeerRequestHandler', () => { expect(result).toEqual(response) expect(handler.handle).toHaveBeenCalledTimes(1) - expect(handler.handle).toHaveBeenCalledWith(request, { context: { context: true }, prefix: '/api' }) + expect(handler.handle).toHaveBeenCalledWith(request, { + context: { context: true }, + prefix: '/api', + }) }) it('returns 404 response when no procedure matched', async () => { @@ -55,7 +62,7 @@ describe('createStandardPeerRequestHandler', () => { handle: vi.fn(async () => ({ matched: false, response: undefined })), } as any - const handleRequest = createStandardPeerRequestHandler(handler, { }) + const handleRequest = createStandardPeerRequestHandler(handler, {}) const lazyRequest = { resolveBody: async () => 'request-body', diff --git a/packages/server/src/adapters/standard-peer/utils.ts b/packages/server/src/adapters/standard-peer/utils.ts index de26e8ec6d..e26f5a0cea 100644 --- a/packages/server/src/adapters/standard-peer/utils.ts +++ b/packages/server/src/adapters/standard-peer/utils.ts @@ -1,14 +1,18 @@ import type { StandardLazyRequest, StandardResponse } from '@standard-server/core' -import type { Context } from '../../context' -import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard' -import { resolveFriendlyStandardHandlerHandleOptions } from '../standard' + +import type { Context } from '../../context.ts' +import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard/index.ts' +import { resolveFriendlyStandardHandlerHandleOptions } from '../standard/index.ts' export function createStandardPeerRequestHandler( handler: StandardHandler, options: FriendlyStandardHandlerHandleOptions, ): (request: StandardLazyRequest) => Promise { return async (request) => { - const { response } = await handler.handle(request, resolveFriendlyStandardHandlerHandleOptions(options)) + const { response } = await handler.handle( + request, + resolveFriendlyStandardHandlerHandleOptions(options), + ) return response ?? { status: 404, headers: {}, body: 'No procedure matched' } } } diff --git a/packages/server/src/adapters/standard/codec.ts b/packages/server/src/adapters/standard/codec.ts index 81a9779b0d..0b4dce8add 100644 --- a/packages/server/src/adapters/standard/codec.ts +++ b/packages/server/src/adapters/standard/codec.ts @@ -1,9 +1,10 @@ import type { AnyORPCError } from '@orpc/client' import type { Promisable } from '@orpc/shared' import type { StandardLazyRequest, StandardResponse } from '@standard-server/core' -import type { Context } from '../../context' -import type { AnyProcedure } from '../../procedure' -import type { ResolvedStandardHandlerHandleOptions } from './handler' + +import type { Context } from '../../context.ts' +import type { AnyProcedure } from '../../procedure.ts' +import type { ResolvedStandardHandlerHandleOptions } from './handler.ts' export interface StandardHandlerCodecResolvedProcedure { path: string[] @@ -14,20 +15,20 @@ export interface StandardHandlerCodecResolvedProcedure { export interface StandardHandlerCodec { resolveProcedure( request: StandardLazyRequest, - options: ResolvedStandardHandlerHandleOptions + options: ResolvedStandardHandlerHandleOptions, ): Promisable encodeOutput( output: unknown, procedure: AnyProcedure, path: string[], - options: ResolvedStandardHandlerHandleOptions + options: ResolvedStandardHandlerHandleOptions, ): Promisable encodeError( error: AnyORPCError, procedure: AnyProcedure, path: string[], - options: ResolvedStandardHandlerHandleOptions + options: ResolvedStandardHandlerHandleOptions, ): Promisable } diff --git a/packages/server/src/adapters/standard/handler.test.ts b/packages/server/src/adapters/standard/handler.test.ts index d752b65ea9..465a7be8f8 100644 --- a/packages/server/src/adapters/standard/handler.test.ts +++ b/packages/server/src/adapters/standard/handler.test.ts @@ -1,10 +1,11 @@ import { ORPCError } from '@orpc/client' import * as sharedExperimental from '@orpc/shared' import { ErrorEvent } from '@standard-server/core' -import { createProcedureClient } from '../../procedure-client' -import { StandardHandler } from './handler' -vi.mock('../../procedure-client', () => ({ +import { createProcedureClient } from '../../procedure-client.ts' +import { StandardHandler } from './handler.ts' + +vi.mock('../../procedure-client.ts', () => ({ createProcedureClient: vi.fn(), })) @@ -103,7 +104,12 @@ describe('standardHandler', () => { }) expect(codec.encodeOutput).toHaveBeenCalledTimes(1) - expect(codec.encodeOutput).toHaveBeenCalledWith('__output__', resolved.procedure, ['ping'], OPTIONS) + expect(codec.encodeOutput).toHaveBeenCalledWith( + '__output__', + resolved.procedure, + ['ping'], + OPTIONS, + ) expect(codec.encodeError).not.toHaveBeenCalled() @@ -114,7 +120,10 @@ describe('standardHandler', () => { context: OPTIONS.context, prefix: OPTIONS.prefix, }) - await expect(routingInterceptor.mock.results[0]!.value).resolves.toEqual({ matched: true, response: OK_RESPONSE }) + await expect(routingInterceptor.mock.results[0]!.value).resolves.toEqual({ + matched: true, + response: OK_RESPONSE, + }) expect(interceptor).toHaveBeenCalledTimes(1) expect(interceptor).toHaveBeenCalledWith({ @@ -174,10 +183,21 @@ describe('standardHandler', () => { expect(result).toEqual({ matched: true, response: OK_RESPONSE }) expect(context).toHaveBeenCalledTimes(1) expect(context).toHaveBeenCalledWith(request) - expect(routingInterceptor).toHaveBeenCalledWith(expect.objectContaining({ context: { db: 'postgres' } })) - expect(codec.resolveProcedure).toHaveBeenCalledWith(request, { context: { db: 'postgres' }, prefix: '/api/v1' }) - expect(createProcedureClient).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ context: { db: 'postgres' } })) - expect(codec.encodeOutput).toHaveBeenCalledWith('__output__', expect.anything(), ['ping'], { context: { db: 'postgres' }, prefix: '/api/v1' }) + expect(routingInterceptor).toHaveBeenCalledWith( + expect.objectContaining({ context: { db: 'postgres' } }), + ) + expect(codec.resolveProcedure).toHaveBeenCalledWith(request, { + context: { db: 'postgres' }, + prefix: '/api/v1', + }) + expect(createProcedureClient).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ context: { db: 'postgres' } }), + ) + expect(codec.encodeOutput).toHaveBeenCalledWith('__output__', expect.anything(), ['ping'], { + context: { db: 'postgres' }, + prefix: '/api/v1', + }) }) it('resolves an async context function', async () => { @@ -189,13 +209,19 @@ describe('standardHandler', () => { expect(context).toHaveBeenCalledTimes(1) expect(context).toHaveBeenCalledWith(request) - expect(codec.resolveProcedure).toHaveBeenCalledWith(request, { context: { db: 'postgres' }, prefix: '/api/v1' }) + expect(codec.resolveProcedure).toHaveBeenCalledWith(request, { + context: { db: 'postgres' }, + prefix: '/api/v1', + }) }) it('does not run the context function when the prefix does not match', async () => { const context = vi.fn(() => ({ db: 'postgres' })) - const result = await handler.handle(makeRequest({ url: '/other/ping' }), { context, prefix: '/api/v1' }) + const result = await handler.handle(makeRequest({ url: '/other/ping' }), { + context, + prefix: '/api/v1', + }) expect(result).toEqual({ matched: false }) expect(context).not.toHaveBeenCalled() @@ -208,7 +234,9 @@ describe('standardHandler', () => { throw error }) - await expect(handler.handle(makeRequest(), { context, prefix: '/api/v1' })).rejects.toBe(error) + await expect(handler.handle(makeRequest(), { context, prefix: '/api/v1' })).rejects.toBe( + error, + ) expect(codec.resolveProcedure).not.toHaveBeenCalled() expect(codec.encodeError).not.toHaveBeenCalled() }) @@ -237,8 +265,16 @@ describe('standardHandler', () => { expect(result).toEqual({ matched: true, response: OK_RESPONSE }) expect(resolved.decodeInput).toHaveBeenCalledOnce() - expect(client).toHaveBeenCalledWith('__input__', expect.objectContaining({ signal: request.signal })) - expect(codec.encodeOutput).toHaveBeenCalledWith('__output__', resolved.procedure, ['ping'], OPTIONS) + expect(client).toHaveBeenCalledWith( + '__input__', + expect.objectContaining({ signal: request.signal }), + ) + expect(codec.encodeOutput).toHaveBeenCalledWith( + '__output__', + resolved.procedure, + ['ping'], + OPTIONS, + ) expect(codec.encodeError).not.toHaveBeenCalled() }) @@ -291,7 +327,12 @@ describe('standardHandler', () => { const result = await handler.handle(makeRequest(), OPTIONS) expect(result).toEqual({ matched: true, response: OK_RESPONSE }) - expect(codec.encodeOutput).toHaveBeenCalledWith(['e1', 'e2'], expect.anything(), ['ping'], expect.anything()) + expect(codec.encodeOutput).toHaveBeenCalledWith( + ['e1', 'e2'], + expect.anything(), + ['ping'], + expect.anything(), + ) }) it('safely traces ReadableStream input', async () => { @@ -313,10 +354,17 @@ describe('standardHandler', () => { const result = await handler.handle(makeRequest(), OPTIONS) expect(result).toEqual({ matched: true, response: OK_RESPONSE }) - expect(codec.encodeOutput).toHaveBeenCalledWith(['chunk'], expect.anything(), ['ping'], expect.anything()) + expect(codec.encodeOutput).toHaveBeenCalledWith( + ['chunk'], + expect.anything(), + ['ping'], + expect.anything(), + ) }) - it('passes AsyncIteratorObject and ReadableStream input through untouched without a tracer', async ({ onTestFinished }) => { + it('passes AsyncIteratorObject and ReadableStream input through untouched without a tracer', async ({ + onTestFinished, + }) => { const tracer = sharedExperimental.getTracer() sharedExperimental.setTracer(undefined) onTestFinished(() => sharedExperimental.setTracer(tracer)) @@ -341,7 +389,10 @@ describe('standardHandler', () => { const result = await handler.handle(makeRequest(), OPTIONS) - expect(result).toEqual({ matched: true, response: { status: 400, headers: {}, body: 'bad request' } }) + expect(result).toEqual({ + matched: true, + response: { status: 400, headers: {}, body: 'bad request' }, + }) expect(createProcedureClient).not.toHaveBeenCalled() const error = codec.encodeError.mock.calls[0]?.[0] @@ -359,7 +410,10 @@ describe('standardHandler', () => { const result = await handler.handle(makeRequest(), OPTIONS) - expect(result).toEqual({ matched: true, response: { status: 413, headers: {}, body: 'too large' } }) + expect(result).toEqual({ + matched: true, + response: { status: 413, headers: {}, body: 'too large' }, + }) expect(codec.encodeError.mock.calls[0]?.[0]).toBe(orpcError) }) @@ -372,8 +426,16 @@ describe('standardHandler', () => { const result = await handler.handle(makeRequest(), OPTIONS) - expect(result).toEqual({ matched: true, response: { status: 502, headers: {}, body: 'bad gateway' } }) - expect(codec.encodeError).toHaveBeenCalledWith(procedureError, expect.anything(), ['ping'], OPTIONS) + expect(result).toEqual({ + matched: true, + response: { status: 502, headers: {}, body: 'bad gateway' }, + }) + expect(codec.encodeError).toHaveBeenCalledWith( + procedureError, + expect.anything(), + ['ping'], + OPTIONS, + ) }) it('wraps non-ORPCError from procedure call as INTERNAL_SERVER_ERROR', async () => { @@ -410,14 +472,23 @@ describe('standardHandler', () => { expect(plugin.init).toHaveBeenCalledOnce() expect(pluginInterceptor).toHaveBeenCalledOnce() - expect(result).toEqual({ matched: true, response: { status: 200, headers: {}, body: 'from-plugin' } }) + expect(result).toEqual({ + matched: true, + response: { status: 200, headers: {}, body: 'from-plugin' }, + }) expect(codec.resolveProcedure).not.toHaveBeenCalled() }) }) describe('tracing', () => { function createSpan() { - return { setAttribute: vi.fn(), updateName: vi.fn(), addEvent: vi.fn(), recordException: vi.fn(), end: vi.fn() } + return { + setAttribute: vi.fn(), + updateName: vi.fn(), + addEvent: vi.fn(), + recordException: vi.fn(), + end: vi.fn(), + } } let span: ReturnType @@ -434,7 +505,9 @@ describe('standardHandler', () => { tracer = { startSpan: vi.fn(() => span), // the request span is the first active span started - startActiveSpan: vi.fn((_name, _parent, fn) => fn(createSpan())).mockImplementationOnce((_name, _parent, fn) => fn(span)), + startActiveSpan: vi + .fn((_name, _parent, fn) => fn(createSpan())) + .mockImplementationOnce((_name, _parent, fn) => fn(span)), withActiveSpan: vi.fn((_span, fn) => fn()), getActiveSpan: () => span, extract: vi.fn(), @@ -465,7 +538,10 @@ describe('standardHandler', () => { sharedExperimental.setTracer(undefined) setupHappyPath() - await expect(handler.handle(makeRequest(), OPTIONS)).resolves.toEqual({ matched: true, response: OK_RESPONSE }) + await expect(handler.handle(makeRequest(), OPTIONS)).resolves.toEqual({ + matched: true, + response: OK_RESPONSE, + }) }) it('starts the request span under the parent extracted from request headers and ends it when nothing matches', async () => { @@ -473,14 +549,26 @@ describe('standardHandler', () => { const parent = { name: 'parent' } tracer.extract!.mockReturnValue(parent) - const request = makeRequest({ headers: { traceparent: '00-test' }, url: '/api/v1/ping?search=1' }) + const request = makeRequest({ + headers: { traceparent: '00-test' }, + url: '/api/v1/ping?search=1', + }) await expect(handler.handle(request, OPTIONS)).resolves.toEqual({ matched: false }) expect(tracer.extract).toHaveBeenCalledWith(request.headers) - expect(tracer.startActiveSpan).toHaveBeenNthCalledWith(1, 'POST /api/v1/ping', parent, expect.any(Function)) + expect(tracer.startActiveSpan).toHaveBeenNthCalledWith( + 1, + 'POST /api/v1/ping', + parent, + expect.any(Function), + ) expect(tracer.startSpan).not.toHaveBeenCalled() expect(tracer.withActiveSpan).not.toHaveBeenCalled() - expect(tracer.startActiveSpan).toHaveBeenCalledWith('find_procedure', undefined, expect.any(Function)) + expect(tracer.startActiveSpan).toHaveBeenCalledWith( + 'find_procedure', + undefined, + expect.any(Function), + ) expect(span.updateName).toHaveBeenCalledWith('orpc_no_match') expect(span.recordException).not.toHaveBeenCalled() expect(span.end).toHaveBeenCalledTimes(1) @@ -492,7 +580,12 @@ describe('standardHandler', () => { await handler.handle(makeRequest(), OPTIONS) - expect(tracer.startActiveSpan).toHaveBeenNthCalledWith(1, 'POST /api/v1/ping', undefined, expect.any(Function)) + expect(tracer.startActiveSpan).toHaveBeenNthCalledWith( + 1, + 'POST /api/v1/ping', + undefined, + expect.any(Function), + ) }) it('ends the request span right away for a non-streaming body', async () => { @@ -505,26 +598,41 @@ describe('standardHandler', () => { it.each([ ['a regular error', new Error('interceptor failure')], ['an abort error', new DOMException('interceptor failure', 'AbortError')], - ])('records %s at error level and ends the request span when handling throws', async (_, error) => { - handler = new StandardHandler(codec as any, { - routingInterceptors: [() => { throw error }], - }) - - await expect(handler.handle(makeRequest(), OPTIONS)).rejects.toBe(error) - - expect(span.recordException).toHaveBeenCalledExactlyOnceWith('error', expect.objectContaining({ message: 'interceptor failure' })) - expect(span.end).toHaveBeenCalledTimes(1) - }) + ])( + 'records %s at error level and ends the request span when handling throws', + async (_, error) => { + handler = new StandardHandler(codec as any, { + routingInterceptors: [ + () => { + throw error + }, + ], + }) + + await expect(handler.handle(makeRequest(), OPTIONS)).rejects.toBe(error) + + expect(span.recordException).toHaveBeenCalledExactlyOnceWith( + 'error', + expect.objectContaining({ message: 'interceptor failure' }), + ) + expect(span.end).toHaveBeenCalledTimes(1) + }, + ) describe.each([ ['async iterator', (body: AsyncIterable) => body], ['readable stream', (body: AsyncIterable) => ReadableStream.from(body)], ])('%s body', (_, toBody) => { it('keeps the request span active and open until the body is drained', async () => { - const body = Object.assign(toBody((async function* () { - yield 'a' - yield 'b' - })()), { custom: 'kept' }) + const body = Object.assign( + toBody( + (async function* () { + yield 'a' + yield 'b' + })(), + ), + { custom: 'kept' }, + ) const wrapped = await handleBody(body) @@ -541,10 +649,14 @@ describe('standardHandler', () => { }) it('ends the request span when the consumer stops early', async () => { - const wrapped = await handleBody(toBody((async function* () { - yield 'a' - yield 'b' - })())) + const wrapped = await handleBody( + toBody( + (async function* () { + yield 'a' + yield 'b' + })(), + ), + ) const iterator = wrapped[Symbol.asyncIterator]() await iterator.next() @@ -555,24 +667,33 @@ describe('standardHandler', () => { }) it('records failures on the request span', async () => { - const wrapped = await handleBody(toBody((async function* () { - yield 'a' - throw new Error('body failure') - })())) + const wrapped = await handleBody( + toBody( + (async function* () { + yield 'a' + throw new Error('body failure') + })(), + ), + ) await expect(drain(wrapped)).rejects.toThrow('body failure') - expect(span.recordException).toHaveBeenCalledExactlyOnceWith('error', expect.objectContaining({ message: 'body failure' })) + expect(span.recordException).toHaveBeenCalledExactlyOnceWith( + 'error', + expect.objectContaining({ message: 'body failure' }), + ) expect(span.end).toHaveBeenCalledTimes(1) }) }) it('does not record ErrorEvent failures of an async iterator body on the request span', async () => { const errorEvent = new ErrorEvent({ code: 'BAD_REQUEST' }) - const wrapped = await handleBody((async function* () { - yield 'a' - throw errorEvent - })()) + const wrapped = await handleBody( + (async function* () { + yield 'a' + throw errorEvent + })(), + ) await expect(drain(wrapped)).rejects.toBe(errorEvent) diff --git a/packages/server/src/adapters/standard/handler.ts b/packages/server/src/adapters/standard/handler.ts index bf2fc52fbf..ef88accf5c 100644 --- a/packages/server/src/adapters/standard/handler.ts +++ b/packages/server/src/adapters/standard/handler.ts @@ -1,15 +1,32 @@ +import { ORPCError, toORPCError } from '@orpc/client' import type { ErrorMap, Schema } from '@orpc/contract' import type { Interceptor, Promisable, Value } from '@orpc/shared' +import { + getTracer, + intercept, + isAsyncIteratorObject, + matchesHttpPathPrefix, + ORPC_NAME, + override, + recordSpanError, + runWithSpan, + toArray, + toTracingException, + traceAsyncIterator, + traceReadableStream, + value, + wrapAsyncIterator, + wrapReadableStream, +} from '@orpc/shared' import type { StandardLazyRequest, StandardResponse } from '@standard-server/core' -import type { Context } from '../../context' -import type { ProcedureClientInterceptor } from '../../procedure-client' -import type { StandardHandlerCodec, StandardHandlerCodecResolvedProcedure } from './codec' -import type { StandardHandlerPlugin } from './plugin' -import { ORPCError, toORPCError } from '@orpc/client' -import { getTracer, intercept, isAsyncIteratorObject, matchesHttpPathPrefix, ORPC_NAME, override, recordSpanError, runWithSpan, toArray, toTracingException, traceAsyncIterator, traceReadableStream, value, wrapAsyncIterator, wrapReadableStream } from '@orpc/shared' import { ErrorEvent, flattenStandardHeader, parseStandardUrl } from '@standard-server/core' -import { createProcedureClient } from '../../procedure-client' -import { CompositeStandardHandlerPlugin } from './plugin' + +import type { Context } from '../../context.ts' +import type { ProcedureClientInterceptor } from '../../procedure-client.ts' +import { createProcedureClient } from '../../procedure-client.ts' +import type { StandardHandlerCodec, StandardHandlerCodecResolvedProcedure } from './codec.ts' +import type { StandardHandlerPlugin } from './plugin.ts' +import { CompositeStandardHandlerPlugin } from './plugin.ts' export interface StandardHandlerHandleOptions { prefix?: `/${string}` | undefined @@ -24,21 +41,35 @@ export interface StandardHandlerHandleOptions { * `StandardHandlerHandleOptions` after the context has been resolved, * this is what interceptors and codecs receive. */ -export interface ResolvedStandardHandlerHandleOptions extends Omit, 'context'> { +export interface ResolvedStandardHandlerHandleOptions extends Omit< + StandardHandlerHandleOptions, + 'context' +> { context: T } -export type StandardHandlerHandleResult = { matched: true, response: StandardResponse } | { matched: false, response?: undefined } +export type StandardHandlerHandleResult = + | { matched: true; response: StandardResponse } + | { matched: false; response?: undefined } -export interface StandardHandlerInterceptorOptions extends StandardHandlerCodecResolvedProcedure, ResolvedStandardHandlerHandleOptions { +export interface StandardHandlerInterceptorOptions + extends StandardHandlerCodecResolvedProcedure, ResolvedStandardHandlerHandleOptions { request: StandardLazyRequest } -export type StandardHandlerInterceptor = Interceptor, Promise> - -export interface StandardHandlerRoutingInterceptorOptions extends ResolvedStandardHandlerHandleOptions { +export type StandardHandlerInterceptor = Interceptor< + StandardHandlerInterceptorOptions, + Promise +> + +export interface StandardHandlerRoutingInterceptorOptions< + T extends Context, +> extends ResolvedStandardHandlerHandleOptions { request: StandardLazyRequest } -export type StandardHandlerRoutingInterceptor = Interceptor, Promise> +export type StandardHandlerRoutingInterceptor = Interceptor< + StandardHandlerRoutingInterceptorOptions, + Promise +> export interface StandardHandlerOptions { /** @@ -86,14 +117,17 @@ export class StandardHandler { this.clientInterceptors = options.clientInterceptors } - async handle(request: StandardLazyRequest, options: StandardHandlerHandleOptions): Promise { + async handle( + request: StandardLazyRequest, + options: StandardHandlerHandleOptions, + ): Promise { const { prefix } = options if (prefix && !matchesHttpPathPrefix(request.url, prefix)) { return { matched: false, response: undefined } } - const context = await value(options.context, request) as T + const context = (await value(options.context, request)) as T return intercept( this.routingInterceptors, @@ -103,7 +137,9 @@ export class StandardHandler { let step: 'decode_input' | 'call_procedure' | undefined - const matchedOrNot = await runWithSpan('find_procedure', () => this.codec.resolveProcedure(request, { context, prefix })) + const matchedOrNot = await runWithSpan('find_procedure', () => + this.codec.resolveProcedure(request, { context, prefix }), + ) if (!matchedOrNot) { /** @@ -140,10 +176,11 @@ export class StandardHandler { * @warning * Remember use `override` for AsyncIteratorObject to remain other special properties */ - input = override(input, traceAsyncIterator('consume_async_iterator_object_input', input)) - } - - else if (getTracer() && input instanceof ReadableStream) { + input = override( + input, + traceAsyncIterator('consume_async_iterator_object_input', input), + ) + } else if (getTracer() && input instanceof ReadableStream) { /** * @warning * Remember use `override` for ReadableStream to remain other special properties @@ -167,15 +204,17 @@ export class StandardHandler { }) step = undefined - const response = await this.codec.encodeOutput(output, procedure, path, { context, prefix }) + const response = await this.codec.encodeOutput(output, procedure, path, { + context, + prefix, + }) return response }, ) return { matched: true, response } - } - catch (e) { + } catch (e) { /** * Only errors that happen outside of the `call_procedure` step should be set as an error. * Because a business logic error should not be considered as a protocol-level error. @@ -184,12 +223,13 @@ export class StandardHandler { recordSpanError(span, e) } - const error = step === 'decode_input' && !(e instanceof ORPCError) - ? new ORPCError('BAD_REQUEST', { - message: `Malformed request. Ensure the request body is properly formatted and the 'Content-Type' header is set correctly.`, - cause: e, - }) - : toORPCError(e) + const error = + step === 'decode_input' && !(e instanceof ORPCError) + ? new ORPCError('BAD_REQUEST', { + message: `Malformed request. Ensure the request body is properly formatted and the 'Content-Type' header is set correctly.`, + cause: e, + }) + : toORPCError(e) const response = await this.codec.encodeError(error, procedure, path, { context, prefix }) @@ -242,22 +282,25 @@ class TracingHandlerPlugin implements StandardHandlerPlugin { * @remarks * **Warning**: Remember use `override` for remaining special properties */ - body: override(body, wrapAsyncIterator(body, { - runWith: fn => tracer.withActiveSpan(span, fn), - onError(error) { - /** - * Errors here are internal (interceptor/framework) failures, - * except `ErrorEvent`: a business error the protocol delivers - * inside the event stream, already logged by the client interceptor. - */ - if (!(error instanceof ErrorEvent)) { - span.recordException('error', toTracingException(error)) - } - }, - onFinish() { - span.end() - }, - })), + body: override( + body, + wrapAsyncIterator(body, { + runWith: (fn) => tracer.withActiveSpan(span, fn), + onError(error) { + /** + * Errors here are internal (interceptor/framework) failures, + * except `ErrorEvent`: a business error the protocol delivers + * inside the event stream, already logged by the client interceptor. + */ + if (!(error instanceof ErrorEvent)) { + span.recordException('error', toTracingException(error)) + } + }, + onFinish() { + span.end() + }, + }), + ), }, } } @@ -271,27 +314,29 @@ class TracingHandlerPlugin implements StandardHandlerPlugin { * @remarks * **Warning**: Remember use `override` for remaining special properties */ - body: override(body, wrapReadableStream(body, { - runWith: fn => tracer.withActiveSpan(span, fn), - onError(error) { - /** - * Any error here is internal (interceptor/framework), not business logic. - * Indicates unexpected handler failure. - */ - span.recordException('error', toTracingException(error)) - }, - onFinish() { - span.end() - }, - })), + body: override( + body, + wrapReadableStream(body, { + runWith: (fn) => tracer.withActiveSpan(span, fn), + onError(error) { + /** + * Any error here is internal (interceptor/framework), not business logic. + * Indicates unexpected handler failure. + */ + span.recordException('error', toTracingException(error)) + }, + onFinish() { + span.end() + }, + }), + ), }, } } span.end() return result - } - catch (e) { + } catch (e) { /** * Any error here is internal (interceptor/framework), not business logic. * Always recorded as an error, even when it is an abort error. diff --git a/packages/server/src/adapters/standard/index.test.ts b/packages/server/src/adapters/standard/index.test.ts index b69575d349..a11243f722 100644 --- a/packages/server/src/adapters/standard/index.test.ts +++ b/packages/server/src/adapters/standard/index.test.ts @@ -1,5 +1,5 @@ it('exports StandardHandler, resolveFriendlyStandardHandlerHandleOptions, RPCMatcher, RPCHandlerCodec', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ StandardHandler: expect.any(Function), resolveFriendlyStandardHandlerHandleOptions: expect.any(Function), RPCMatcher: expect.any(Function), diff --git a/packages/server/src/adapters/standard/index.ts b/packages/server/src/adapters/standard/index.ts index e84a94c5ec..d30632cb7c 100644 --- a/packages/server/src/adapters/standard/index.ts +++ b/packages/server/src/adapters/standard/index.ts @@ -1,6 +1,6 @@ -export * from './codec' -export * from './handler' -export type { StandardHandlerPlugin } from './plugin' -export * from './rpc-handler-codec' -export * from './rpc-matcher' -export * from './utils' +export * from './codec.ts' +export * from './handler.ts' +export type { StandardHandlerPlugin } from './plugin.ts' +export * from './rpc-handler-codec.ts' +export * from './rpc-matcher.ts' +export * from './utils.ts' diff --git a/packages/server/src/adapters/standard/plugin.test.ts b/packages/server/src/adapters/standard/plugin.test.ts index f753dc7e0b..0895e6bcc0 100644 --- a/packages/server/src/adapters/standard/plugin.test.ts +++ b/packages/server/src/adapters/standard/plugin.test.ts @@ -1,5 +1,5 @@ -import type { StandardHandlerPlugin } from './plugin' -import { CompositeStandardHandlerPlugin } from './plugin' +import type { StandardHandlerPlugin } from './plugin.ts' +import { CompositeStandardHandlerPlugin } from './plugin.ts' describe('compositeStandardHandlerPlugin', () => { beforeEach(() => { diff --git a/packages/server/src/adapters/standard/plugin.ts b/packages/server/src/adapters/standard/plugin.ts index c5c6dd31a9..4d046a2da8 100644 --- a/packages/server/src/adapters/standard/plugin.ts +++ b/packages/server/src/adapters/standard/plugin.ts @@ -1,8 +1,9 @@ import type { OrderablePlugin } from '@orpc/shared' -import type { Context } from '../../context' -import type { StandardHandlerOptions } from './handler' import { sortPlugins } from '@orpc/shared' +import type { Context } from '../../context.ts' +import type { StandardHandlerOptions } from './handler.ts' + export interface StandardHandlerPlugin extends OrderablePlugin { /** * Initializes the plugin and returns new handler options. diff --git a/packages/server/src/adapters/standard/rpc-handler-codec.test.ts b/packages/server/src/adapters/standard/rpc-handler-codec.test.ts index 996d76fc3d..6904b0a72c 100644 --- a/packages/server/src/adapters/standard/rpc-handler-codec.test.ts +++ b/packages/server/src/adapters/standard/rpc-handler-codec.test.ts @@ -1,7 +1,8 @@ import { ORPCError } from '@orpc/client' -import { os } from '../../builder' -import { DEFAULT_ERROR_STATUS } from '../../constants' -import { RPCHandlerCodec } from './rpc-handler-codec' + +import { os } from '../../builder.ts' +import { DEFAULT_ERROR_STATUS } from '../../constants.ts' +import { RPCHandlerCodec } from './rpc-handler-codec.ts' beforeEach(() => { vi.clearAllMocks() @@ -21,13 +22,16 @@ describe('rpcHandlerCodec', () => { it('returns undefined when no procedure matches', async () => { const codec = new RPCHandlerCodec(router) - const result = await codec.resolveProcedure({ - method: 'POST', - url: '/missing?data=%7B%7D', - resolveBody: vi.fn(), - headers: {}, - signal: undefined, - } as any, options as any) + const result = await codec.resolveProcedure( + { + method: 'POST', + url: '/missing?data=%7B%7D', + resolveBody: vi.fn(), + headers: {}, + signal: undefined, + } as any, + options as any, + ) expect(result).toBeUndefined() }) @@ -40,13 +44,16 @@ describe('rpcHandlerCodec', () => { const codec = new RPCHandlerCodec(router, { serializer, allowMethods: ['GET'] }) - const result = await codec.resolveProcedure({ - method: 'GET', - url: '/ping?data=%7B%22json%22%3A%22first%22%7D&data=%7B%22json%22%3A%22second%22%7D', - resolveBody: vi.fn(), - headers: {}, - signal: undefined, - } as any, options as any) + const result = await codec.resolveProcedure( + { + method: 'GET', + url: '/ping?data=%7B%22json%22%3A%22first%22%7D&data=%7B%22json%22%3A%22second%22%7D', + resolveBody: vi.fn(), + headers: {}, + signal: undefined, + } as any, + options as any, + ) expect(result).toBeDefined() expect(result!.path).toEqual(['ping']) @@ -69,13 +76,16 @@ describe('rpcHandlerCodec', () => { const codec = new RPCHandlerCodec(router, { serializer }) - const result = await codec.resolveProcedure({ - method: 'POST', - url: '/ping', - resolveBody, - headers: {}, - signal: undefined, - } as any, options as any) + const result = await codec.resolveProcedure( + { + method: 'POST', + url: '/ping', + resolveBody, + headers: {}, + signal: undefined, + } as any, + options as any, + ) expect(result).toBeDefined() @@ -96,13 +106,16 @@ describe('rpcHandlerCodec', () => { const resolveBody = vi.fn().mockResolvedValueOnce({ json: null }) const codec = new RPCHandlerCodec(router, { serializer, allowMethods: ['QUERY'] }) - const result = await codec.resolveProcedure({ - method: 'QUERY', - url: '/ping', - resolveBody, - headers: {}, - signal: undefined, - } as any, options as any) + const result = await codec.resolveProcedure( + { + method: 'QUERY', + url: '/ping', + resolveBody, + headers: {}, + signal: undefined, + } as any, + options as any, + ) expect(result).toBeDefined() @@ -163,7 +176,8 @@ describe('rpcHandlerCodec', () => { it('treats outputStatus undefined or null as fallback to 200', () => { const serializer = { - serialize: vi.fn() + serialize: vi + .fn() .mockReturnValueOnce('__serialized_null__') .mockReturnValueOnce('__serialized_undefined__'), deserialize: vi.fn(), @@ -182,14 +196,24 @@ describe('rpcHandlerCodec', () => { outputStatus, }) - const nullStatusResponse = codec.encodeOutput('__output_null__', procedure as any, ['ping'], options as any) + const nullStatusResponse = codec.encodeOutput( + '__output_null__', + procedure as any, + ['ping'], + options as any, + ) expect(nullStatusResponse).toEqual({ headers: {}, status: 200, body: '__serialized_null__', }) - const undefinedStatusResponse = codec.encodeOutput('__output_undefined__', procedure as any, ['ping'], options as any) + const undefinedStatusResponse = codec.encodeOutput( + '__output_undefined__', + procedure as any, + ['ping'], + options as any, + ) expect(undefinedStatusResponse).toEqual({ headers: {}, status: 200, @@ -197,8 +221,20 @@ describe('rpcHandlerCodec', () => { }) expect(outputStatus).toHaveBeenCalledTimes(2) - expect(outputStatus).toHaveBeenNthCalledWith(1, '__output_null__', procedure, ['ping'], options) - expect(outputStatus).toHaveBeenNthCalledWith(2, '__output_undefined__', procedure, ['ping'], options) + expect(outputStatus).toHaveBeenNthCalledWith( + 1, + '__output_null__', + procedure, + ['ping'], + options, + ) + expect(outputStatus).toHaveBeenNthCalledWith( + 2, + '__output_undefined__', + procedure, + ['ping'], + options, + ) expect(serializer.serialize).toHaveBeenCalledTimes(2) }) }) @@ -230,12 +266,15 @@ describe('rpcHandlerCodec', () => { const codec = new RPCHandlerCodec(router, { serializer }) const error = new ORPCError('toString' as any) - expect((await codec.encodeError(error, procedure as any, ['ping'], options as any)).status).toEqual(DEFAULT_ERROR_STATUS) + expect( + (await codec.encodeError(error, procedure as any, ['ping'], options as any)).status, + ).toEqual(DEFAULT_ERROR_STATUS) }) it('custom status with errorStatusCodes option', () => { const serializer = { - serialize: vi.fn() + serialize: vi + .fn() .mockReturnValueOnce('__serialized_override__') .mockReturnValueOnce('__serialized_fallback__'), deserialize: vi.fn(), @@ -247,7 +286,12 @@ describe('rpcHandlerCodec', () => { }) const overriddenError = new ORPCError('BAD_GATEWAY') - const overriddenResponse = codec.encodeError(overriddenError, procedure as any, ['ping'], options as any) + const overriddenResponse = codec.encodeError( + overriddenError, + procedure as any, + ['ping'], + options as any, + ) expect(overriddenResponse).toEqual({ headers: {}, @@ -256,7 +300,12 @@ describe('rpcHandlerCodec', () => { }) const unknownError = new ORPCError('UNKNOWN_CODE' as any) - const fallbackResponse = codec.encodeError(unknownError, procedure as any, ['ping'], options as any) + const fallbackResponse = codec.encodeError( + unknownError, + procedure as any, + ['ping'], + options as any, + ) expect(fallbackResponse).toEqual({ headers: {}, @@ -271,7 +320,8 @@ describe('rpcHandlerCodec', () => { it('fallback to DEFAULT_ERROR_STATUS for unknown error', () => { const serializer = { - serialize: vi.fn() + serialize: vi + .fn() .mockReturnValueOnce('__serialized_custom__') .mockReturnValueOnce('__serialized_fallback__'), deserialize: vi.fn(), @@ -282,7 +332,12 @@ describe('rpcHandlerCodec', () => { }) const customizedError = new ORPCError('BAD_GATEWAY') - const customizedResponse = codec.encodeError(customizedError, procedure as any, ['ping'], options as any) + const customizedResponse = codec.encodeError( + customizedError, + procedure as any, + ['ping'], + options as any, + ) expect(customizedResponse).toEqual({ headers: {}, @@ -291,7 +346,12 @@ describe('rpcHandlerCodec', () => { }) const fallbackError = new ORPCError('UNKNOWN_ERROR') - const fallbackResponse = codec.encodeError(fallbackError, procedure as any, ['ping'], options as any) + const fallbackResponse = codec.encodeError( + fallbackError, + procedure as any, + ['ping'], + options as any, + ) expect(fallbackResponse).toEqual({ headers: {}, @@ -310,13 +370,16 @@ describe('rpcHandlerCodec', () => { filter, }) - const result = await codec.resolveProcedure({ - method: 'POST', - url: '/missing?data=%7B%7D', - resolveBody: vi.fn(), - headers: {}, - signal: undefined, - } as any, options as any) + const result = await codec.resolveProcedure( + { + method: 'POST', + url: '/missing?data=%7B%7D', + resolveBody: vi.fn(), + headers: {}, + signal: undefined, + } as any, + options as any, + ) expect(result).toBeUndefined() expect(filter).toHaveBeenCalled() diff --git a/packages/server/src/adapters/standard/rpc-handler-codec.ts b/packages/server/src/adapters/standard/rpc-handler-codec.ts index bb2f1190f7..faecfb714f 100644 --- a/packages/server/src/adapters/standard/rpc-handler-codec.ts +++ b/packages/server/src/adapters/standard/rpc-handler-codec.ts @@ -1,16 +1,21 @@ import type { AnyORPCError } from '@orpc/client' -import type { Promisable, Public, Value } from '@orpc/shared' -import type { StandardLazyRequest, StandardResponse } from '@standard-server/core' -import type { Context } from '../../context' -import type { AnyProcedure } from '../../procedure' -import type { AnyRouter } from '../../router' -import type { ResolvedStandardHandlerHandleOptions, StandardHandlerCodec, StandardHandlerCodecResolvedProcedure } from '../standard' -import type { RPCMatcherOptions } from './rpc-matcher' import { COMMON_ERROR_STATUS_MAP, RPCSerializer } from '@orpc/client' +import type { Promisable, Public, Value } from '@orpc/shared' import { getOwn, parseEmptyableJSON, value } from '@orpc/shared' +import type { StandardLazyRequest, StandardResponse } from '@standard-server/core' import { parseStandardUrl } from '@standard-server/core' -import { DEFAULT_ERROR_STATUS, DEFAULT_SUCCESS_STATUS } from '../../constants' -import { RPCMatcher } from './rpc-matcher' + +import { DEFAULT_ERROR_STATUS, DEFAULT_SUCCESS_STATUS } from '../../constants.ts' +import type { Context } from '../../context.ts' +import type { AnyProcedure } from '../../procedure.ts' +import type { AnyRouter } from '../../router.ts' +import type { + ResolvedStandardHandlerHandleOptions, + StandardHandlerCodec, + StandardHandlerCodecResolvedProcedure, +} from '../standard/index.ts' +import type { RPCMatcherOptions } from './rpc-matcher.ts' +import { RPCMatcher } from './rpc-matcher.ts' export interface RPCHandlerCodecOptions extends RPCMatcherOptions { /** @@ -26,7 +31,17 @@ export interface RPCHandlerCodecOptions extends RPCMatcherOpt * * @default DEFAULT_SUCCESS_STATUS (200) */ - outputStatus?: Value]> | undefined + outputStatus?: + | Value< + number | undefined | null, + [ + output: unknown, + procedure: AnyProcedure, + path: string[], + options: ResolvedStandardHandlerHandleOptions, + ] + > + | undefined /** * Mapping ORPCError Code -> HTTP Status Code @@ -51,7 +66,10 @@ export class RPCHandlerCodec implements StandardHandlerCodec< this.outputStatus = options.outputStatus } - async resolveProcedure(request: StandardLazyRequest, options: ResolvedStandardHandlerHandleOptions): Promise { + async resolveProcedure( + request: StandardLazyRequest, + options: ResolvedStandardHandlerHandleOptions, + ): Promise { const [pathname, query] = parseStandardUrl(request.url) const matched = await this.matcher.match(request.method, pathname, options.prefix) @@ -64,7 +82,7 @@ export class RPCHandlerCodec implements StandardHandlerCodec< path: matched.path, decodeInput: async () => { if (request.method === 'GET') { - const dataString = (new URLSearchParams(query)).getAll('data').at(-1) + const dataString = new URLSearchParams(query).getAll('data').at(-1) return this.serializer.deserialize(parseEmptyableJSON(dataString)) } @@ -74,7 +92,12 @@ export class RPCHandlerCodec implements StandardHandlerCodec< } } - encodeOutput(output: unknown, procedure: AnyProcedure, path: string[], options: ResolvedStandardHandlerHandleOptions): Promisable { + encodeOutput( + output: unknown, + procedure: AnyProcedure, + path: string[], + options: ResolvedStandardHandlerHandleOptions, + ): Promisable { return { headers: {}, status: value(this.outputStatus, output, procedure, path, options) ?? DEFAULT_SUCCESS_STATUS, @@ -82,7 +105,12 @@ export class RPCHandlerCodec implements StandardHandlerCodec< } } - encodeError(error: AnyORPCError, _procedure: AnyProcedure, _path: string[], _options: ResolvedStandardHandlerHandleOptions): Promisable { + encodeError( + error: AnyORPCError, + _procedure: AnyProcedure, + _path: string[], + _options: ResolvedStandardHandlerHandleOptions, + ): Promisable { const status = getOwn(this.errorStatusMap, error.code) ?? DEFAULT_ERROR_STATUS return { diff --git a/packages/server/src/adapters/standard/rpc-matcher.test.ts b/packages/server/src/adapters/standard/rpc-matcher.test.ts index c6a5c6b5be..e509757ee6 100644 --- a/packages/server/src/adapters/standard/rpc-matcher.test.ts +++ b/packages/server/src/adapters/standard/rpc-matcher.test.ts @@ -1,11 +1,12 @@ import { oc } from '@orpc/contract' import { z } from 'zod' -import { os } from '../../builder' -import { implement } from '../../implementer' -import { Lazy } from '../../lazy' -import * as ProcedureUtils from '../../procedure-utils' -import { withHiddenRouterContract } from '../../router-hidden' -import { RPCMatcher } from './rpc-matcher' + +import { os } from '../../builder.ts' +import { implement } from '../../implementer.ts' +import { Lazy } from '../../lazy.ts' +import * as ProcedureUtils from '../../procedure-utils.ts' +import { withHiddenRouterContract } from '../../router-hidden.ts' +import { RPCMatcher } from './rpc-matcher.ts' const createContractProcedureSpy = vi.spyOn(ProcedureUtils, 'createContractProcedure') @@ -22,13 +23,15 @@ describe('rpcMatcher', () => { const procedure3 = os.errors({ BAD_GATEWAY: {} }).handler(() => {}) const lazyLazyLoader = vi.fn(async () => ({ default: { deep: procedure1 } })) - const lazyLoader = vi.fn(async () => ({ default: { - info: procedure3, - lazy: new Lazy({ - loader: lazyLazyLoader, - meta: {}, - }), - } })) + const lazyLoader = vi.fn(async () => ({ + default: { + info: procedure3, + lazy: new Lazy({ + loader: lazyLazyLoader, + meta: {}, + }), + }, + })) const router = { ping: procedure1, @@ -182,7 +185,9 @@ describe('rpcMatcher', () => { await Promise.resolve() } - return { default: depth === 3 ? { leaf } : { leaf, [`l${depth + 1}`]: lazyLevel(depth + 1) } } + return { + default: depth === 3 ? { leaf } : { leaf, [`l${depth + 1}`]: lazyLevel(depth + 1) }, + } }, }) } @@ -191,13 +196,13 @@ describe('rpcMatcher', () => { const paths = ['/l1/leaf', '/l1/l2/leaf', '/l1/l2/l3/leaf'] as const const results = await Promise.all( - paths.flatMap(path => [ + paths.flatMap((path) => [ matcher.match('POST', path, undefined), matcher.match('POST', path, undefined), ]), ) - expect(results.map(result => result?.path.join('/'))).toEqual([ + expect(results.map((result) => result?.path.join('/'))).toEqual([ 'l1/leaf', 'l1/leaf', 'l1/l2/leaf', @@ -221,7 +226,9 @@ describe('rpcMatcher', () => { const hasSpy = vi.spyOn(pending, 'has') const getSpy = vi.spyOn(pending, 'get') - await expect(matcher.match('POST', `/${'/'.repeat(16_000)}%25`, undefined)).resolves.toBeUndefined() + await expect( + matcher.match('POST', `/${'/'.repeat(16_000)}%25`, undefined), + ).resolves.toBeUndefined() // max pending depth is 1, and there are two passes (raw + normalized) expect(hasSpy.mock.calls.length + getSpy.mock.calls.length).toBeLessThanOrEqual(2) @@ -253,28 +260,23 @@ describe('rpcMatcher', () => { }) describe('allowMethods', () => { - it.each([ - 'POST', - 'PUT', - 'PATCH', - 'DELETE', - ] as const)('matches %s requests by default', async (method) => { - const matcher = new RPCMatcher(router) + it.each(['POST', 'PUT', 'PATCH', 'DELETE'] as const)( + 'matches %s requests by default', + async (method) => { + const matcher = new RPCMatcher(router) - await expect(matcher.match(method, '/ping', undefined)).resolves.toBeDefined() - }) + await expect(matcher.match(method, '/ping', undefined)).resolves.toBeDefined() + }, + ) - it.each([ - 'GET', - 'HEAD', - 'OPTIONS', - 'QUERY', - 'TRACE', - ] as const)('treats %s requests as unmatched by default', async (method) => { - const matcher = new RPCMatcher(router) + it.each(['GET', 'HEAD', 'OPTIONS', 'QUERY', 'TRACE'] as const)( + 'treats %s requests as unmatched by default', + async (method) => { + const matcher = new RPCMatcher(router) - await expect(matcher.match(method, '/ping', undefined)).resolves.toBeUndefined() - }) + await expect(matcher.match(method, '/ping', undefined)).resolves.toBeUndefined() + }, + ) it('replaces the default allowlist entirely when a list is provided', async () => { const matcher = new RPCMatcher(router, { allowMethods: ['GET', 'QUERY'] }) @@ -285,7 +287,10 @@ describe('rpcMatcher', () => { }) it('decides per request with the method, matched procedure, and path', async () => { - const allowMethods = vi.fn((method: string, _procedure: unknown, path: string[]) => method === 'GET' && path[0] === 'ping') + const allowMethods = vi.fn( + (method: string, _procedure: unknown, path: string[]) => + method === 'GET' && path[0] === 'ping', + ) const matcher = new RPCMatcher(router, { allowMethods }) const matched = await matcher.match('GET', '/ping', undefined) @@ -388,8 +393,14 @@ describe('rpcMatcher', () => { it('strips trailing slash after prefix stripping', async () => { const matcher = new RPCMatcher(router) - await expect(matcher.match('POST', '/api/ping/', '/api/')).resolves.toEqual({ path: ['ping'], procedure: procedure1 }) - await expect(matcher.match('POST', '/api/ping/', '/api')).resolves.toEqual({ path: ['ping'], procedure: procedure1 }) + await expect(matcher.match('POST', '/api/ping/', '/api/')).resolves.toEqual({ + path: ['ping'], + procedure: procedure1, + }) + await expect(matcher.match('POST', '/api/ping/', '/api')).resolves.toEqual({ + path: ['ping'], + procedure: procedure1, + }) }) }) @@ -416,7 +427,11 @@ describe('rpcMatcher', () => { expect(r2).toBeDefined() expect(r2!.path).toEqual(['nested', 'echo']) expect(createContractProcedureSpy).toHaveBeenCalledTimes(2) - expect(createContractProcedureSpy).toHaveBeenNthCalledWith(2, router.nested.echo, contract.nested.echo) + expect(createContractProcedureSpy).toHaveBeenNthCalledWith( + 2, + router.nested.echo, + contract.nested.echo, + ) expect(r2!.procedure).not.toBe(procedure2) expect(r2!.procedure).toBe(createContractProcedureSpy.mock.results[1]!.value) @@ -441,15 +456,22 @@ describe('rpcMatcher', () => { } const matcher = new RPCMatcher(withHiddenRouterContract(router, contract)) - await expect(matcher.match('POST', '/missing', undefined)).rejects.toThrowError('[Contract-First] Missing or invalid implementation for procedure at path: "missing"') + await expect(matcher.match('POST', '/missing', undefined)).rejects.toThrowError( + '[Contract-First] Missing or invalid implementation for procedure at path: "missing"', + ) }) it('uses the implemented handler and config when implementing a server router', async () => { const original = { - ping: os.$config({ disableInputValidation: true }).input(z.string()).handler(() => 'original'), + ping: os + .$config({ disableInputValidation: true }) + .input(z.string()) + .handler(() => 'original'), } const implementer = implement(original, { disableInputValidation: false }) - const matcher = new RPCMatcher(implementer.router({ ping: implementer.ping.handler(() => 'implemented') })) + const matcher = new RPCMatcher( + implementer.router({ ping: implementer.ping.handler(() => 'implemented') }), + ) const { procedure } = (await matcher.match('POST', '/ping', undefined))! @@ -490,7 +512,11 @@ describe('rpcMatcher', () => { const matcher = new RPCMatcher({ 'v1^beta': { 'items:batchGet': procedure1 } }) // WHATWG URL and Node's req.url keep "^" raw, while procedure paths are stored as "%5E" - for (const pathname of ['/v1^beta/items:batchGet', '/v1%5Ebeta/items%3AbatchGet', '/v1%5ebeta/items:batchGet'] as const) { + for (const pathname of [ + '/v1^beta/items:batchGet', + '/v1%5Ebeta/items%3AbatchGet', + '/v1%5ebeta/items:batchGet', + ] as const) { const result = await matcher.match('POST', pathname, undefined) expect(result).toBeDefined() @@ -498,7 +524,9 @@ describe('rpcMatcher', () => { expect(result!.procedure).toBe(procedure1) } - await expect(matcher.match('POST', '/v1beta/items:batchGet', undefined)).resolves.toBeUndefined() + await expect( + matcher.match('POST', '/v1beta/items:batchGet', undefined), + ).resolves.toBeUndefined() }) it('resolves lazy routers found only after normalizing a raw character in the pathname', async () => { diff --git a/packages/server/src/adapters/standard/rpc-matcher.ts b/packages/server/src/adapters/standard/rpc-matcher.ts index 7cf44ccb7d..2b6776cd0b 100644 --- a/packages/server/src/adapters/standard/rpc-matcher.ts +++ b/packages/server/src/adapters/standard/rpc-matcher.ts @@ -1,13 +1,14 @@ import type { AnyProcedureContract } from '@orpc/contract' import type { Value } from '@orpc/shared' -import type { StandardMethod } from '@standard-server/core' -import type { AnyProcedure } from '../../procedure' -import type { AnyRouter } from '../../router' -import type { WalkProcedureContractsLazyResult } from '../../router-utils' import { normalizeHttpPath, pathToHttpPath, value } from '@orpc/shared' -import { unlazy } from '../../lazy' -import { Procedure } from '../../procedure' -import { resolveContractProcedure, walkProcedureContractsSync } from '../../router-utils' +import type { StandardMethod } from '@standard-server/core' + +import { unlazy } from '../../lazy.ts' +import type { AnyProcedure } from '../../procedure.ts' +import { Procedure } from '../../procedure.ts' +import type { WalkProcedureContractsLazyResult } from '../../router-utils.ts' +import { resolveContractProcedure, walkProcedureContractsSync } from '../../router-utils.ts' +import type { AnyRouter } from '../../router.ts' /** * Methods that can invoke procedures by default. Browsers cannot trigger them @@ -18,7 +19,12 @@ import { resolveContractProcedure, walkProcedureContractsSync } from '../../rout * * @see {@link https://orpc.dev/docs/rpc/handler#supported-http-methods | RPC Handler - Supported HTTP Methods} */ -export const RPC_DEFAULT_ALLOW_METHODS: readonly StandardMethod[] = ['POST', 'PUT', 'PATCH', 'DELETE'] +export const RPC_DEFAULT_ALLOW_METHODS: readonly StandardMethod[] = [ + 'POST', + 'PUT', + 'PATCH', + 'DELETE', +] export interface RPCMatcherOptions { /** @@ -26,7 +32,9 @@ export interface RPCMatcherOptions { * * @default true */ - filter?: Value | undefined + filter?: + | Value + | undefined /** * Restricts which HTTP methods can invoke procedures, either with a list of allowed @@ -37,7 +45,10 @@ export interface RPCMatcherOptions { * @default RPC_DEFAULT_ALLOW_METHODS (['POST', 'PUT', 'PATCH', 'DELETE']) * @see {@link https://orpc.dev/docs/rpc/handler#supported-http-methods | RPC Handler - Supported HTTP Methods} */ - allowMethods?: readonly StandardMethod[] | ((method: StandardMethod, procedure: AnyProcedure, path: string[]) => boolean) | undefined + allowMethods?: + | readonly StandardMethod[] + | ((method: StandardMethod, procedure: AnyProcedure, path: string[]) => boolean) + | undefined } // `%` or a char `encodeURIComponent` encodes, meaning normalization may change the pathname @@ -57,7 +68,9 @@ interface PendingLazyRouter extends WalkProcedureContractsLazyResult { export class RPCMatcher { private readonly filter: Exclude private readonly allowMethodsSet: undefined | ReadonlySet - private readonly allowMethodsFn: undefined | ((method: StandardMethod, procedure: AnyProcedure, path: string[]) => boolean) + private readonly allowMethodsFn: + | undefined + | ((method: StandardMethod, procedure: AnyProcedure, path: string[]) => boolean) private readonly rootRouter: AnyRouter private readonly tree: Map<`/${string}`, TreeEntry> = new Map() @@ -71,8 +84,7 @@ export class RPCMatcher { const allowMethods = options.allowMethods ?? RPC_DEFAULT_ALLOW_METHODS if (typeof allowMethods !== 'function') { this.allowMethodsSet = new Set(allowMethods) - } - else { + } else { this.allowMethodsFn = allowMethods } @@ -81,18 +93,22 @@ export class RPCMatcher { } private index(router: AnyRouter, path: string[] = []): void { - const lazyResults = walkProcedureContractsSync(router, (procedure, procedurePath) => { - if (!value(this.filter, procedure, procedurePath)) { - return - } - - this.tree.set(pathToHttpPath(procedurePath), { - path: procedurePath, - contract: procedure, - // contract-first entries have no implementation yet, resolveProcedure fills it in on demand - procedure: procedure instanceof Procedure ? procedure : undefined, - }) - }, path) + const lazyResults = walkProcedureContractsSync( + router, + (procedure, procedurePath) => { + if (!value(this.filter, procedure, procedurePath)) { + return + } + + this.tree.set(pathToHttpPath(procedurePath), { + path: procedurePath, + contract: procedure, + // contract-first entries have no implementation yet, resolveProcedure fills it in on demand + procedure: procedure instanceof Procedure ? procedure : undefined, + }) + }, + path, + ) for (const result of lazyResults) { this.pendingLazyRouters.set(pathToHttpPath(result.path), result) @@ -100,7 +116,11 @@ export class RPCMatcher { } } - async match(method: StandardMethod, pathname: `/${string}`, prefix: `/${string}` | undefined): Promise<{ path: string[], procedure: AnyProcedure } | undefined> { + async match( + method: StandardMethod, + pathname: `/${string}`, + prefix: `/${string}` | undefined, + ): Promise<{ path: string[]; procedure: AnyProcedure } | undefined> { if (this.allowMethodsSet && !this.allowMethodsSet.has(method)) { return undefined } @@ -114,14 +134,11 @@ export class RPCMatcher { if (charAfterPrefix === '/') { pathname = pathname.slice(prefix.length) as `/${string}` - } - else if (charAfterPrefix === undefined) { + } else if (charAfterPrefix === undefined) { pathname = '/' - } - else if (prefix[prefix.length - 1] === '/') { + } else if (prefix[prefix.length - 1] === '/') { pathname = pathname.slice(prefix.length - 1) as `/${string}` - } - else { + } else { return undefined } } @@ -158,7 +175,7 @@ export class RPCMatcher { return undefined } - const procedure = entry.procedure ?? await this.resolveProcedure(entry) + const procedure = entry.procedure ?? (await this.resolveProcedure(entry)) if (this.allowMethodsFn && !this.allowMethodsFn(method, procedure, entry.path)) { return undefined @@ -185,7 +202,9 @@ export class RPCMatcher { const pending = this.pendingLazyRouters.get(httpPathPrefix) if (pending !== undefined) { - return this.loadPendingLazyRouter(httpPathPrefix, pending).then(() => this.resolvePendingLazyRouters(pathname)) + return this.loadPendingLazyRouter(httpPathPrefix, pending).then(() => + this.resolvePendingLazyRouters(pathname), + ) } slashIndex = nextSlashIndex @@ -203,7 +222,10 @@ export class RPCMatcher { return pending.loading } - private async indexPendingLazyRouter(httpPathPrefix: string, pending: PendingLazyRouter): Promise { + private async indexPendingLazyRouter( + httpPathPrefix: string, + pending: PendingLazyRouter, + ): Promise { const { default: router } = await unlazy(pending.router) this.index(router, pending.path) diff --git a/packages/server/src/adapters/standard/utils.test.ts b/packages/server/src/adapters/standard/utils.test.ts index 47824309df..dec250dc3b 100644 --- a/packages/server/src/adapters/standard/utils.test.ts +++ b/packages/server/src/adapters/standard/utils.test.ts @@ -1,4 +1,4 @@ -import { resolveFriendlyStandardHandlerHandleOptions } from './utils' +import { resolveFriendlyStandardHandlerHandleOptions } from './utils.ts' describe('resolveFriendlyStandardHandlerHandleOptions', () => { it('defaults context to empty object', () => { @@ -12,10 +12,12 @@ describe('resolveFriendlyStandardHandlerHandleOptions', () => { }) it('preserves provided context and prefix', () => { - expect(resolveFriendlyStandardHandlerHandleOptions({ - prefix: '/api/v1', - context: { userId: 'u_123' }, - })).toEqual({ + expect( + resolveFriendlyStandardHandlerHandleOptions({ + prefix: '/api/v1', + context: { userId: 'u_123' }, + }), + ).toEqual({ prefix: '/api/v1', context: { userId: 'u_123' }, }) diff --git a/packages/server/src/adapters/standard/utils.ts b/packages/server/src/adapters/standard/utils.ts index 7474cf56b8..29063a695f 100644 --- a/packages/server/src/adapters/standard/utils.ts +++ b/packages/server/src/adapters/standard/utils.ts @@ -1,15 +1,22 @@ import type { Promisable, Value } from '@orpc/shared' import type { StandardLazyRequest } from '@standard-server/core' -import type { Context } from '../../context' -import type { StandardHandlerHandleOptions } from './handler' -export type FriendlyStandardHandlerHandleOptions - = & Omit, 'context'> - & (object extends T ? { context?: Value, [request: StandardLazyRequest]> | undefined } : { context: Value, [request: StandardLazyRequest]> }) +import type { Context } from '../../context.ts' +import type { StandardHandlerHandleOptions } from './handler.ts' -export function resolveFriendlyStandardHandlerHandleOptions(options: FriendlyStandardHandlerHandleOptions): StandardHandlerHandleOptions { +export type FriendlyStandardHandlerHandleOptions = Omit< + StandardHandlerHandleOptions, + 'context' +> & + (object extends T + ? { context?: Value, [request: StandardLazyRequest]> | undefined } + : { context: Value, [request: StandardLazyRequest]> }) + +export function resolveFriendlyStandardHandlerHandleOptions( + options: FriendlyStandardHandlerHandleOptions, +): StandardHandlerHandleOptions { return { ...options, - context: options.context ?? {} as T, // Context only optional if all fields are optional + context: options.context ?? ({} as T), // Context only optional if all fields are optional } } diff --git a/packages/server/src/adapters/websocket/handler.test-d.ts b/packages/server/src/adapters/websocket/handler.test-d.ts index 6eecae57b8..7fc202f92b 100644 --- a/packages/server/src/adapters/websocket/handler.test-d.ts +++ b/packages/server/src/adapters/websocket/handler.test-d.ts @@ -1,4 +1,4 @@ -import type { WebSocketLike } from './handler' +import type { WebSocketLike } from './handler.ts' it('WebSocket is assignable to WebSocketLike', () => { const _assignable: WebSocketLike = {} as WebSocket diff --git a/packages/server/src/adapters/websocket/handler.ts b/packages/server/src/adapters/websocket/handler.ts index 7a4858c5c1..951b44e627 100644 --- a/packages/server/src/adapters/websocket/handler.ts +++ b/packages/server/src/adapters/websocket/handler.ts @@ -1,10 +1,16 @@ import type { Arrayable, MaybeOptionalOptions } from '@orpc/shared' -import type { DecodePeerMessageOptions, EncodePeerMessageOptions } from '@standard-server/peer' -import type { Context } from '../../context' -import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard' import { loadBytes, resolveMaybeOptionalOptions, sequential, toStringOrBytes } from '@orpc/shared' -import { decodePeerMessage, encodePeerMessage, isClientPeerSendMessage, ServerPeer } from '@standard-server/peer' -import { createStandardPeerRequestHandler } from '../standard-peer' +import type { DecodePeerMessageOptions, EncodePeerMessageOptions } from '@standard-server/peer' +import { + decodePeerMessage, + encodePeerMessage, + isClientPeerSendMessage, + ServerPeer, +} from '@standard-server/peer' + +import type { Context } from '../../context.ts' +import { createStandardPeerRequestHandler } from '../standard-peer/index.ts' +import type { FriendlyStandardHandlerHandleOptions, StandardHandler } from '../standard/index.ts' /** * Supports standard WebSocket instances, Bun ServerWebSocket, "ws" ServerWebSocket, @@ -56,7 +62,9 @@ export class WebSocketHandler { */ async message( ws: WebSocketLike, - data: Arrayable, 'buffer' | 'byteOffset' | 'byteLength'>>, + data: Arrayable< + string | ArrayBuffer | Pick, 'buffer' | 'byteOffset' | 'byteLength'> + >, ...rest: MaybeOptionalOptions> ): Promise<{ matched: boolean }> { /** @@ -74,10 +82,13 @@ export class WebSocketHandler { } if (!peer) { - this.peers.set(ws, peer = new ServerPeer(async (message) => { - const encoded = await encodePeerMessage(message, this.encodePeerMessageOptions) - await ws.send(encoded) - })) + this.peers.set( + ws, + (peer = new ServerPeer(async (message) => { + const encoded = await encodePeerMessage(message, this.encodePeerMessageOptions) + await ws.send(encoded) + })), + ) } if (result.matched && isClientPeerSendMessage(result.message)) { @@ -115,14 +126,17 @@ export class WebSocketHandler { * Message order is important: loading -> decode -> .message. * This flow must stay synchronous, or we need to use `sequential` helper */ - ws.addEventListener('message', sequential(async (event) => { - // For better compatibility avoid control or depend on websocket.binaryType - const data = event.data instanceof Blob ? await loadBytes(event.data) : event.data - - // Not awaited: `this.message` runs business logic that may be slow, - // and awaiting it would block decoding of subsequent messages. - this.message(ws, data, ...rest) - })) + ws.addEventListener( + 'message', + sequential(async (event) => { + // For better compatibility avoid control or depend on websocket.binaryType + const data = event.data instanceof Blob ? await loadBytes(event.data) : event.data + + // Not awaited: `this.message` runs business logic that may be slow, + // and awaiting it would block decoding of subsequent messages. + void this.message(ws, data, ...rest) + }), + ) ws.addEventListener('close', () => this.close(ws)) // EventEmitter-based implementations like `ws` throw an unhandled `error` event, diff --git a/packages/server/src/adapters/websocket/index.test.ts b/packages/server/src/adapters/websocket/index.test.ts index f77e52dcdc..b010072ddc 100644 --- a/packages/server/src/adapters/websocket/index.test.ts +++ b/packages/server/src/adapters/websocket/index.test.ts @@ -1,3 +1,3 @@ it('exports RPCHandler', async () => { - await expect(import('.')).resolves.toHaveProperty('RPCHandler') + await expect(import('./index.ts')).resolves.toHaveProperty('RPCHandler') }) diff --git a/packages/server/src/adapters/websocket/index.ts b/packages/server/src/adapters/websocket/index.ts index 97d3dd64c7..4e8b799a20 100644 --- a/packages/server/src/adapters/websocket/index.ts +++ b/packages/server/src/adapters/websocket/index.ts @@ -1,2 +1,2 @@ -export * from './handler' -export * from './rpc-handler' +export * from './handler.ts' +export * from './rpc-handler.ts' diff --git a/packages/server/src/adapters/websocket/rpc-handler.test.ts b/packages/server/src/adapters/websocket/rpc-handler.test.ts index b29167b6ec..69eaa0bbb4 100644 --- a/packages/server/src/adapters/websocket/rpc-handler.test.ts +++ b/packages/server/src/adapters/websocket/rpc-handler.test.ts @@ -1,9 +1,11 @@ -import type { AddressInfo } from 'node:net' import { once } from 'node:events' +import type { AddressInfo } from 'node:net' + import { decodePeerMessage, encodePeerMessage } from '@standard-server/peer' -import WebSocket, { WebSocketServer } from 'ws' -import { os } from '../../builder' -import { RPCHandler } from './rpc-handler' +import { WebSocket, WebSocketServer } from 'ws' + +import { os } from '../../builder.ts' +import { RPCHandler } from './rpc-handler.ts' describe('rpcHandler', () => { beforeEach(() => { @@ -11,14 +13,17 @@ describe('rpcHandler', () => { }) const createHandler = (options: ConstructorParameters[1] = {}) => { - return new RPCHandler({ - ping: os.handler(async ({ signal }) => { - await new Promise(resolve => setTimeout(resolve, 10)) - signal?.throwIfAborted() - - return 'pong' - }), - }, options) + return new RPCHandler( + { + ping: os.handler(async ({ signal }) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + signal?.throwIfAborted() + + return 'pong' + }), + }, + options, + ) } const createWs = () => { @@ -36,24 +41,25 @@ describe('rpcHandler', () => { const createRequestMessage = async ({ prefix, url = '/ping', - }: { prefix?: string, url?: `/${string}` } = {}) => { - return encodePeerMessage({ - id: '19', - kind: 'request', - json: { - url, - body: { json: 'input' }, - headers: {}, - method: 'POST', + }: { prefix?: string; url?: `/${string}` } = {}) => { + return encodePeerMessage( + { + id: '19', + kind: 'request', + json: { + url, + body: { json: 'input' }, + headers: {}, + method: 'POST', + }, }, - }, prefix ? { prefix } : undefined) + prefix ? { prefix } : undefined, + ) } it('accepts context and prefix option in message method', async () => { const handler = new RPCHandler({ - ping: os - .$context<{ userId: string }>() - .handler(({ context }) => context.userId), + ping: os.$context<{ userId: string }>().handler(({ context }) => context.userId), }) const ws = createWs() @@ -77,18 +83,27 @@ describe('rpcHandler', () => { it.each([ ['string', () => createRequestMessage()], - ['bytes', async () => { - const message = await createRequestMessage() - return new TextEncoder().encode(message as string) - }], - ['arrayBuffer', async () => { - const message = await createRequestMessage() - return new TextEncoder().encode(message as string).buffer - }], - ['arrayBuffer parts', async () => { - const message = await createRequestMessage() - return [new TextEncoder().encode(message as string).buffer] - }], + [ + 'bytes', + async () => { + const message = await createRequestMessage() + return new TextEncoder().encode(message as string) + }, + ], + [ + 'arrayBuffer', + async () => { + const message = await createRequestMessage() + return new TextEncoder().encode(message as string).buffer + }, + ], + [ + 'arrayBuffer parts', + async () => { + const message = await createRequestMessage() + return [new TextEncoder().encode(message as string).buffer] + }, + ], ])('handles %s request', async (_type, createMessage) => { const handler = createHandler() const ws = createWs() @@ -207,14 +222,14 @@ describe('rpcHandler', () => { const handler = createHandler() const wss = new WebSocketServer({ port: 0 }) - wss.on('connection', ws => handler.upgrade(ws)) + wss.on('connection', (ws) => handler.upgrade(ws)) onTestFinished(() => wss.close()) const client = new WebSocket(`ws://localhost:${(wss.address() as AddressInfo).port}`) await once(client, 'open') // `ws` rejects text frames carrying invalid UTF-8 by emitting `error` on the server socket - client.send(new Uint8Array([0xFF]), { binary: false }) + client.send(new Uint8Array([0xff]), { binary: false }) const [code] = await once(client, 'close') expect(code).toBe(1007) diff --git a/packages/server/src/adapters/websocket/rpc-handler.ts b/packages/server/src/adapters/websocket/rpc-handler.ts index 01890450a2..b74b1682b4 100644 --- a/packages/server/src/adapters/websocket/rpc-handler.ts +++ b/packages/server/src/adapters/websocket/rpc-handler.ts @@ -1,9 +1,9 @@ -import type { Context } from '../../context' -import type { Router } from '../../router' -import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard' -import type { WebSocketHandlerOptions } from './handler' -import { RPCHandlerCodec, StandardHandler } from '../standard' -import { WebSocketHandler } from './handler' +import type { Context } from '../../context.ts' +import type { Router } from '../../router.ts' +import type { RPCHandlerCodecOptions, StandardHandlerOptions } from '../standard/index.ts' +import { RPCHandlerCodec, StandardHandler } from '../standard/index.ts' +import type { WebSocketHandlerOptions } from './handler.ts' +import { WebSocketHandler } from './handler.ts' export interface RPCHandlerOptions extends StandardHandlerOptions, RPCHandlerCodecOptions, WebSocketHandlerOptions {} @@ -15,10 +15,7 @@ export interface RPCHandlerOptions * @see {@link https://orpc.dev/docs/adapters/websocket | WebSocket Adapters} */ export class RPCHandler extends WebSocketHandler { - constructor( - router: Router, - options: NoInfer> = {}, - ) { + constructor(router: Router, options: NoInfer> = {}) { const codec = new RPCHandlerCodec(router, options) const handler = new StandardHandler(codec, options) super(handler, options) diff --git a/packages/server/src/builder-variants.test-d.ts b/packages/server/src/builder-variants.test-d.ts index d93a6834a3..67f31df2b9 100644 --- a/packages/server/src/builder-variants.test-d.ts +++ b/packages/server/src/builder-variants.test-d.ts @@ -1,15 +1,27 @@ -import type { MergedErrorMap, MergedSchema, MetaPlugin, ORPCErrorConstructorMap, Schema } from '@orpc/contract' -import type { BuilderWithInput, BuilderWithInputOutput, BuilderWithMiddlewares, BuilderWithOutput } from './builder-variants' -import type { MergedContext, MergedInitialContext } from './context' -import type { Lazy } from './lazy' -import type { Middleware, MiddlewareDone } from './middleware' -import type { DecoratedMiddleware } from './middleware-decorated' -import type { Procedure } from './procedure' -import type { DecoratedProcedure } from './procedure-decorated' -import type { AugmentedRouterWithMiddlewares } from './router-utils' +import type { + MergedErrorMap, + MergedSchema, + MetaPlugin, + ORPCErrorConstructorMap, + Schema, +} from '@orpc/contract' import { expectTypeOf } from 'vitest' import { z } from 'zod' +import type { + BuilderWithInput, + BuilderWithInputOutput, + BuilderWithMiddlewares, + BuilderWithOutput, +} from './builder-variants.ts' +import type { MergedContext, MergedInitialContext } from './context.ts' +import type { Lazy } from './lazy.ts' +import type { DecoratedMiddleware } from './middleware-decorated.ts' +import type { Middleware, MiddlewareDone } from './middleware.ts' +import type { DecoratedProcedure } from './procedure-decorated.ts' +import type { Procedure } from './procedure.ts' +import type { AugmentedRouterWithMiddlewares } from './router-utils.ts' + const errorMap = { BASE: { data: z.object({ id: z.string() }), @@ -17,11 +29,15 @@ const errorMap = { }, } -const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +const schema1 = z.object({ schema1: z.number().transform((n) => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform((n) => `${n}`) }) describe('BuilderWithMiddlewares', () => { - const builder = {} as BuilderWithMiddlewares<{ auth: boolean }, { extra: boolean }, typeof errorMap> + const builder = {} as BuilderWithMiddlewares< + { auth: boolean }, + { extra: boolean }, + typeof errorMap + > it('.meta', () => { const plugin = { name: 'test', init: (m: any) => m } @@ -77,11 +93,21 @@ describe('BuilderWithMiddlewares', () => { }) it('outline middleware', () => { - const middleware = {} as Middleware<{ auth: boolean, extra: boolean, g?: boolean }, { more: string }, unknown, any, { SOME_ERROR: { message: string } }> + const middleware = {} as Middleware< + { auth: boolean; extra: boolean; g?: boolean }, + { more: string }, + unknown, + any, + { SOME_ERROR: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithMiddlewares< - MergedInitialContext<{ auth: boolean }, { extra: boolean }, { auth: boolean, extra: boolean, g?: boolean }>, + MergedInitialContext< + { auth: boolean }, + { extra: boolean }, + { auth: boolean; extra: boolean; g?: boolean } + >, MergedContext<{ extra: boolean }, { more: string }>, MergedErrorMap<{ SOME_ERROR: { message: string } }, typeof errorMap> > @@ -94,7 +120,9 @@ describe('BuilderWithMiddlewares', () => { void middleware.use({} as Middleware) // @ts-expect-error - TInContext is not satisfy expected - void middleware.use({} as Middleware<{ does_not_satisfy: string }, object, unknown, any, object>) + void middleware.use( + {} as Middleware<{ does_not_satisfy: string }, object, unknown, any, object>, + ) // @ts-expect-error - conflict with TInitialContext void builder.use({} as Middleware) @@ -103,8 +131,14 @@ describe('BuilderWithMiddlewares', () => { void builder.use({} as Middleware) }) - it('low-priority mid\'s errors and ignore conflicts', () => { - const middleware = {} as Middleware<{ auth: boolean, extra: boolean }, object, unknown, any, { BASE: { message: 'CONFLICT' }, EXTRA: { message: string } }> + it("low-priority mid's errors and ignore conflicts", () => { + const middleware = {} as Middleware< + { auth: boolean; extra: boolean }, + object, + unknown, + any, + { BASE: { message: 'CONFLICT' }; EXTRA: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithMiddlewares< @@ -120,21 +154,33 @@ describe('BuilderWithMiddlewares', () => { it('pure', () => { // default expected TOutput should = any to ensure this middleware can be used in any procedure - expectTypeOf(builder.middleware(({ next, context, errors }, input, done) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(done).toEqualTypeOf>() - expectTypeOf(context).toEqualTypeOf>() - expectTypeOf(errors).toEqualTypeOf>() - - return next() - })).toEqualTypeOf< + expectTypeOf( + builder.middleware(({ next, context, errors }, input, done) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(done).toEqualTypeOf>() + expectTypeOf(context).toEqualTypeOf< + MergedContext<{ auth: boolean }, { extra: boolean }> + >() + expectTypeOf(errors).toEqualTypeOf>() + + return next() + }), + ).toEqualTypeOf< DecoratedMiddleware<{ auth: boolean }, { extra: boolean }, unknown, any, typeof errorMap> >() }) it('with TOutContext', () => { - expectTypeOf(builder.middleware(({ next }) => next({ context: { more: 'yes' } }))).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean }, MergedContext<{ extra: boolean }, { more: string }>, unknown, any, typeof errorMap> + expectTypeOf( + builder.middleware(({ next }) => next({ context: { more: 'yes' } })), + ).toEqualTypeOf< + DecoratedMiddleware< + { auth: boolean }, + MergedContext<{ extra: boolean }, { more: string }>, + unknown, + any, + typeof errorMap + > >() // @ts-expect-error - conflict with TInitialContext @@ -145,14 +191,32 @@ describe('BuilderWithMiddlewares', () => { }) it('with expected input', () => { - expectTypeOf(builder.middleware(({ next }, input: { schema1: number }) => next({}))).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean }, { extra: boolean }, { schema1: number }, any, typeof errorMap> + expectTypeOf( + builder.middleware(({ next }, input: { schema1: number }) => next({})), + ).toEqualTypeOf< + DecoratedMiddleware< + { auth: boolean }, + { extra: boolean }, + { schema1: number }, + any, + typeof errorMap + > >() }) it('with expected output', () => { - expectTypeOf(builder.middleware(({ next }, input, done: MiddlewareDone<{ schema2: number }>) => next({}))).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean }, { extra: boolean }, unknown, { schema2: number }, typeof errorMap> + expectTypeOf( + builder.middleware(({ next }, input, done: MiddlewareDone<{ schema2: number }>) => + next({}), + ), + ).toEqualTypeOf< + DecoratedMiddleware< + { auth: boolean }, + { extra: boolean }, + unknown, + { schema2: number }, + typeof errorMap + > >() }) }) @@ -177,13 +241,17 @@ describe('BuilderWithMiddlewares', () => { describe('.handler', () => { it('simple', () => { - expectTypeOf(builder.handler(async ({ errors, context }, input) => { - expectTypeOf(errors).toEqualTypeOf>() - expectTypeOf(context).toEqualTypeOf>() - expectTypeOf(input).toEqualTypeOf() - - return 'out' - })).toEqualTypeOf< + expectTypeOf( + builder.handler(async ({ errors, context }, input) => { + expectTypeOf(errors).toEqualTypeOf>() + expectTypeOf(context).toEqualTypeOf< + MergedContext<{ auth: boolean }, { extra: boolean }> + >() + expectTypeOf(input).toEqualTypeOf() + + return 'out' + }), + ).toEqualTypeOf< DecoratedProcedure< { auth: boolean }, { extra: boolean }, @@ -197,44 +265,109 @@ describe('BuilderWithMiddlewares', () => { it('.router', () => { const router = { - ping: {} as Procedure<{ auth: boolean, extra: boolean }, object, Schema, Schema, object>, - ping2: {} as Procedure<{ auth: boolean, extra: boolean, something?: boolean }, object, Schema, Schema, object>, + ping: {} as Procedure< + { auth: boolean; extra: boolean }, + object, + Schema, + Schema, + object + >, + ping2: {} as Procedure< + { auth: boolean; extra: boolean; something?: boolean }, + object, + Schema, + Schema, + object + >, } expectTypeOf(builder.router(router)).toEqualTypeOf< - AugmentedRouterWithMiddlewares + AugmentedRouterWithMiddlewares< + typeof router, + { auth: boolean }, + { extra: boolean }, + typeof errorMap + > >() builder.router({ // @ts-expect-error - extra is invalid - ping: {} as Procedure<{ auth: boolean, extra: 'invalid' }, object, Schema, Schema, object>, + ping: {} as Procedure< + { auth: boolean; extra: 'invalid' }, + object, + Schema, + Schema, + object + >, // @ts-expect-error - something is required but missing in builder - ping2: {} as Procedure<{ auth: boolean, something: boolean }, object, Schema, Schema, object>, + ping2: {} as Procedure< + { auth: boolean; something: boolean }, + object, + Schema, + Schema, + object + >, }) }) it('.lazy', () => { const router = { - ping: {} as Procedure<{ auth: boolean, extra: boolean }, object, Schema, Schema, object>, - ping2: {} as Procedure<{ auth: boolean, extra: boolean, something?: boolean }, object, Schema, Schema, object>, + ping: {} as Procedure< + { auth: boolean; extra: boolean }, + object, + Schema, + Schema, + object + >, + ping2: {} as Procedure< + { auth: boolean; extra: boolean; something?: boolean }, + object, + Schema, + Schema, + object + >, } expectTypeOf(builder.lazy(async () => ({ default: router }))).toEqualTypeOf< - Lazy> + Lazy< + AugmentedRouterWithMiddlewares< + typeof router, + { auth: boolean }, + { extra: boolean }, + typeof errorMap + > + > >() // @ts-expect-error - extra is invalid and something is required but missing in builder builder.lazy(async () => ({ default: { - ping: {} as Procedure<{ auth: boolean, extra: 'invalid' }, object, Schema, Schema, object>, - ping2: {} as Procedure<{ auth: boolean, something: boolean }, object, Schema, Schema, object>, + ping: {} as Procedure< + { auth: boolean; extra: 'invalid' }, + object, + Schema, + Schema, + object + >, + ping2: {} as Procedure< + { auth: boolean; something: boolean }, + object, + Schema, + Schema, + object + >, }, })) }) }) describe('BuilderWithInput', () => { - const builder = {} as BuilderWithInput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof errorMap> + const builder = {} as BuilderWithInput< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof errorMap + > it('.meta', () => { const plugin = { name: 'test', init: (m: any) => m } @@ -296,11 +429,21 @@ describe('BuilderWithInput', () => { }) it('outline middleware', () => { - const middleware = {} as Middleware<{ auth: boolean, extra: boolean, g?: boolean }, { more: boolean }, unknown, any, { SOME_ERROR: { message: string } }> + const middleware = {} as Middleware< + { auth: boolean; extra: boolean; g?: boolean }, + { more: boolean }, + unknown, + any, + { SOME_ERROR: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithInput< - MergedInitialContext<{ auth: boolean }, { extra: boolean }, { auth: boolean, extra: boolean, g?: boolean }>, + MergedInitialContext< + { auth: boolean }, + { extra: boolean }, + { auth: boolean; extra: boolean; g?: boolean } + >, MergedContext<{ extra: boolean }, { more: boolean }>, typeof schema1, MergedErrorMap<{ SOME_ERROR: { message: string } }, typeof errorMap> @@ -323,8 +466,14 @@ describe('BuilderWithInput', () => { void builder.use({} as Middleware) }) - it('low-priority mid\'s errors and ignore conflicts', () => { - const middleware = {} as Middleware<{ auth: boolean }, object, unknown, any, { BASE: { message: 'CONFLICT' }, EXTRA: { message: string } }> + it("low-priority mid's errors and ignore conflicts", () => { + const middleware = {} as Middleware< + { auth: boolean }, + object, + unknown, + any, + { BASE: { message: 'CONFLICT' }; EXTRA: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithInput< @@ -353,7 +502,13 @@ describe('BuilderWithInput', () => { it('.output', () => { expectTypeOf(builder.output(schema2)).toEqualTypeOf< - BuilderWithInputOutput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema2, typeof errorMap> + BuilderWithInputOutput< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap + > >() // @ts-expect-error - invalid schema @@ -362,13 +517,17 @@ describe('BuilderWithInput', () => { describe('.handler', () => { it('simple', () => { - expectTypeOf(builder.handler(async ({ errors, context }, input) => { - expectTypeOf(errors).toEqualTypeOf>() - expectTypeOf(context).toEqualTypeOf>() - expectTypeOf(input).toEqualTypeOf<{ schema1: string }>() - - return 'out' - })).toEqualTypeOf< + expectTypeOf( + builder.handler(async ({ errors, context }, input) => { + expectTypeOf(errors).toEqualTypeOf>() + expectTypeOf(context).toEqualTypeOf< + MergedContext<{ auth: boolean }, { extra: boolean }> + >() + expectTypeOf(input).toEqualTypeOf<{ schema1: string }>() + + return 'out' + }), + ).toEqualTypeOf< DecoratedProcedure< { auth: boolean }, { extra: boolean }, @@ -382,7 +541,12 @@ describe('BuilderWithInput', () => { }) describe('BuilderWithOutput', () => { - const builder = {} as BuilderWithOutput<{ auth: boolean }, { extra: boolean }, typeof schema2, typeof errorMap> + const builder = {} as BuilderWithOutput< + { auth: boolean }, + { extra: boolean }, + typeof schema2, + typeof errorMap + > it('.meta', () => { const plugin = { name: 'test', init: (m: any) => m } @@ -444,11 +608,21 @@ describe('BuilderWithOutput', () => { }) it('outline middleware', () => { - const middleware = {} as Middleware<{ auth: boolean, extra: boolean, g?: boolean }, { more: boolean }, unknown, any, { SOME_ERROR: { message: string } }> + const middleware = {} as Middleware< + { auth: boolean; extra: boolean; g?: boolean }, + { more: boolean }, + unknown, + any, + { SOME_ERROR: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithOutput< - MergedInitialContext<{ auth: boolean }, { extra: boolean }, { auth: boolean, extra: boolean, g?: boolean }>, + MergedInitialContext< + { auth: boolean }, + { extra: boolean }, + { auth: boolean; extra: boolean; g?: boolean } + >, MergedContext<{ extra: boolean }, { more: boolean }>, typeof schema2, MergedErrorMap<{ SOME_ERROR: { message: string } }, typeof errorMap> @@ -471,8 +645,14 @@ describe('BuilderWithOutput', () => { void builder.use({} as Middleware) }) - it('low-priority mid\'s errors and ignore conflicts', () => { - const middleware = {} as Middleware<{ auth: boolean }, object, unknown, any, { BASE: { message: 'CONFLICT' }, EXTRA: { message: string } }> + it("low-priority mid's errors and ignore conflicts", () => { + const middleware = {} as Middleware< + { auth: boolean }, + object, + unknown, + any, + { BASE: { message: 'CONFLICT' }; EXTRA: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithOutput< @@ -487,7 +667,13 @@ describe('BuilderWithOutput', () => { it('.input', () => { expectTypeOf(builder.input(schema1)).toEqualTypeOf< - BuilderWithInputOutput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema2, typeof errorMap> + BuilderWithInputOutput< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap + > >() // @ts-expect-error - invalid schema @@ -537,7 +723,13 @@ describe('BuilderWithOutput', () => { }) describe('BuilderWithInputOutput', () => { - const builder = {} as BuilderWithInputOutput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema2, typeof errorMap> + const builder = {} as BuilderWithInputOutput< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap + > it('.meta', () => { const plugin = { name: 'test', init: (m: any) => m } @@ -601,11 +793,21 @@ describe('BuilderWithInputOutput', () => { }) it('outline middleware', () => { - const middleware = {} as Middleware<{ auth: boolean, extra: boolean, g?: boolean }, { more: boolean }, unknown, any, { SOME_ERROR: { message: string } }> + const middleware = {} as Middleware< + { auth: boolean; extra: boolean; g?: boolean }, + { more: boolean }, + unknown, + any, + { SOME_ERROR: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithInputOutput< - MergedInitialContext<{ auth: boolean }, { extra: boolean }, { auth: boolean, extra: boolean, g?: boolean }>, + MergedInitialContext< + { auth: boolean }, + { extra: boolean }, + { auth: boolean; extra: boolean; g?: boolean } + >, MergedContext<{ extra: boolean }, { more: boolean }>, typeof schema1, typeof schema2, @@ -629,8 +831,14 @@ describe('BuilderWithInputOutput', () => { void builder.use({} as Middleware) }) - it('low-priority mid\'s errors and ignore conflicts', () => { - const middleware = {} as Middleware<{ auth: boolean }, object, unknown, any, { BASE: { message: 'CONFLICT' }, EXTRA: { message: string } }> + it("low-priority mid's errors and ignore conflicts", () => { + const middleware = {} as Middleware< + { auth: boolean }, + object, + unknown, + any, + { BASE: { message: 'CONFLICT' }; EXTRA: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithInputOutput< diff --git a/packages/server/src/builder-variants.ts b/packages/server/src/builder-variants.ts index 8e295433a0..1655773378 100644 --- a/packages/server/src/builder-variants.ts +++ b/packages/server/src/builder-variants.ts @@ -1,14 +1,27 @@ -import type { AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, InitialInputSchema, InitialOutputSchema, MergedErrorMap, MergedSchema, MetaPlugin, ORPCErrorConstructorMap, Schema } from '@orpc/contract' +import type { + AnySchema, + ErrorMap, + InferSchemaInput, + InferSchemaOutput, + InitialInputSchema, + InitialOutputSchema, + MergedErrorMap, + MergedSchema, + MetaPlugin, + ORPCErrorConstructorMap, + Schema, +} from '@orpc/contract' import type { IntersectPick } from '@orpc/shared' -import type { BuilderDefinition } from './builder' -import type { Context, MergedContext, MergedInitialContext } from './context' -import type { Lazy } from './lazy' -import type { Middleware } from './middleware' -import type { DecoratedMiddleware } from './middleware-decorated' -import type { ProcedureHandler } from './procedure' -import type { DecoratedProcedure } from './procedure-decorated' -import type { Router } from './router' -import type { AugmentedRouterWithMiddlewares } from './router-utils' + +import type { BuilderDefinition } from './builder.ts' +import type { Context, MergedContext, MergedInitialContext } from './context.ts' +import type { Lazy } from './lazy.ts' +import type { DecoratedMiddleware } from './middleware-decorated.ts' +import type { Middleware } from './middleware.ts' +import type { DecoratedProcedure } from './procedure-decorated.ts' +import type { ProcedureHandler } from './procedure.ts' +import type { AugmentedRouterWithMiddlewares } from './router-utils.ts' +import type { Router } from './router.ts' /** * The builder variant returned after `.use` is called. @@ -49,7 +62,10 @@ export interface BuilderWithMiddlewares< * @see {@link https://orpc.dev/docs/middleware | Middleware} */ 'use'< - $OutContext extends IntersectPick, $OutContext>, + $OutContext extends IntersectPick< + MergedContext, + $OutContext + >, $InContext extends Context = MergedContext, $ErrorMap extends ErrorMap = TErrorMap, >( @@ -73,7 +89,10 @@ export interface BuilderWithMiddlewares< * @see {@link https://orpc.dev/docs/middleware | Middleware} */ 'middleware'< - $OutContext extends IntersectPick, $OutContext>, + $OutContext extends IntersectPick< + MergedContext, + $OutContext + >, $Input, $InContext extends Context = MergedContext, $Output = any, // $Output = any by default is important to make middleware can be used in any output by default, @@ -99,7 +118,7 @@ export interface BuilderWithMiddlewares< * @see {@link https://orpc.dev/docs/procedure#inputoutput-validation | Procedure - Input/Output Validation} */ 'input'( - schema: T + schema: T, ): BuilderWithInput /** @@ -108,7 +127,7 @@ export interface BuilderWithMiddlewares< * @see {@link https://orpc.dev/docs/procedure#inputoutput-validation | Procedure - Input/Output Validation} */ 'output'( - schema: T + schema: T, ): BuilderWithOutput /** @@ -118,14 +137,13 @@ export interface BuilderWithMiddlewares< * @see {@link https://orpc.dev/docs/procedure | Procedure} */ 'handler'( - handler: ProcedureHandler, InferSchemaOutput, T, ORPCErrorConstructorMap>, - ): DecoratedProcedure< - TInitialContext, - TInjectedContext, - InitialInputSchema, - Schema, - TErrorMap - > + handler: ProcedureHandler< + MergedContext, + InferSchemaOutput, + T, + ORPCErrorConstructorMap + >, + ): DecoratedProcedure, TErrorMap> /** * Applies the builder's middleware, errors, and metadata to every procedure @@ -188,7 +206,10 @@ export interface BuilderWithInput< * @see {@link https://orpc.dev/docs/middleware | Middleware} */ 'use'< - $OutContext extends IntersectPick, $OutContext>, + $OutContext extends IntersectPick< + MergedContext, + $OutContext + >, $InContext extends Context = MergedContext, $ErrorMap extends ErrorMap = TErrorMap, >( @@ -212,7 +233,7 @@ export interface BuilderWithInput< * @see {@link https://orpc.dev/docs/procedure#multiple-schemas | Procedure - Multiple Schemas} */ 'input'( - schema: T + schema: T, ): BuilderWithInput, TErrorMap> /** @@ -221,7 +242,7 @@ export interface BuilderWithInput< * @see {@link https://orpc.dev/docs/procedure#inputoutput-validation | Procedure - Input/Output Validation} */ 'output'( - schema: T + schema: T, ): BuilderWithInputOutput /** @@ -231,14 +252,13 @@ export interface BuilderWithInput< * @see {@link https://orpc.dev/docs/procedure | Procedure} */ 'handler'( - handler: ProcedureHandler, InferSchemaOutput, T, ORPCErrorConstructorMap>, - ): DecoratedProcedure< - TInitialContext, - TInjectedContext, - TInputSchema, - Schema, - TErrorMap - > + handler: ProcedureHandler< + MergedContext, + InferSchemaOutput, + T, + ORPCErrorConstructorMap + >, + ): DecoratedProcedure, TErrorMap> } /** @@ -272,7 +292,12 @@ export interface BuilderWithOutput< */ 'errors'( errors: T, - ): BuilderWithOutput> + ): BuilderWithOutput< + TInitialContext, + TInjectedContext, + TOutputSchema, + MergedErrorMap + > /** * Applies a middleware that runs before the handler and can access the @@ -281,7 +306,10 @@ export interface BuilderWithOutput< * @see {@link https://orpc.dev/docs/middleware | Middleware} */ 'use'< - $OutContext extends IntersectPick, $OutContext>, + $OutContext extends IntersectPick< + MergedContext, + $OutContext + >, $InContext extends Context = MergedContext, $ErrorMap extends ErrorMap = TErrorMap, >( @@ -305,7 +333,7 @@ export interface BuilderWithOutput< * @see {@link https://orpc.dev/docs/procedure#inputoutput-validation | Procedure - Input/Output Validation} */ 'input'( - schema: T + schema: T, ): BuilderWithInputOutput /** @@ -314,7 +342,7 @@ export interface BuilderWithOutput< * @see {@link https://orpc.dev/docs/procedure#multiple-schemas | Procedure - Multiple Schemas} */ 'output'( - schema: T + schema: T, ): BuilderWithOutput, TErrorMap> /** @@ -324,8 +352,19 @@ export interface BuilderWithOutput< * @see {@link https://orpc.dev/docs/procedure | Procedure} */ 'handler'>( - handler: ProcedureHandler, InferSchemaOutput, T, ORPCErrorConstructorMap>, - ): DecoratedProcedure + handler: ProcedureHandler< + MergedContext, + InferSchemaOutput, + T, + ORPCErrorConstructorMap + >, + ): DecoratedProcedure< + TInitialContext, + TInjectedContext, + InitialInputSchema, + TOutputSchema, + TErrorMap + > } /** @@ -350,7 +389,13 @@ export interface BuilderWithInputOutput< */ 'meta'( ...plugins: MetaPlugin[] - ): BuilderWithInputOutput + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + TInputSchema, + TOutputSchema, + TErrorMap + > /** * Defines typesafe errors that procedures built from this builder can throw @@ -360,7 +405,13 @@ export interface BuilderWithInputOutput< */ 'errors'( errors: T, - ): BuilderWithInputOutput> + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + TInputSchema, + TOutputSchema, + MergedErrorMap + > /** * Applies a middleware that runs before the handler and can access the @@ -369,7 +420,10 @@ export interface BuilderWithInputOutput< * @see {@link https://orpc.dev/docs/middleware | Middleware} */ 'use'< - $OutContext extends IntersectPick, $OutContext>, + $OutContext extends IntersectPick< + MergedContext, + $OutContext + >, $InContext extends Context = MergedContext, $ErrorMap extends ErrorMap = TErrorMap, >( @@ -394,8 +448,14 @@ export interface BuilderWithInputOutput< * @see {@link https://orpc.dev/docs/procedure#multiple-schemas | Procedure - Multiple Schemas} */ 'input'( - schema: T - ): BuilderWithInputOutput, TOutputSchema, TErrorMap> + schema: T, + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + MergedSchema, + TOutputSchema, + TErrorMap + > /** * Adds an additional output schema, merged with the previously defined one. @@ -403,8 +463,14 @@ export interface BuilderWithInputOutput< * @see {@link https://orpc.dev/docs/procedure#multiple-schemas | Procedure - Multiple Schemas} */ 'output'( - schema: T - ): BuilderWithInputOutput, TErrorMap> + schema: T, + ): BuilderWithInputOutput< + TInitialContext, + TInjectedContext, + TInputSchema, + MergedSchema, + TErrorMap + > /** * Defines the function that implements the procedure and completes the @@ -413,6 +479,11 @@ export interface BuilderWithInputOutput< * @see {@link https://orpc.dev/docs/procedure | Procedure} */ 'handler'>( - handler: ProcedureHandler, InferSchemaOutput, T, ORPCErrorConstructorMap>, + handler: ProcedureHandler< + MergedContext, + InferSchemaOutput, + T, + ORPCErrorConstructorMap + >, ): DecoratedProcedure } diff --git a/packages/server/src/builder.test-d.ts b/packages/server/src/builder.test-d.ts index fbd51d33fa..e7180b8777 100644 --- a/packages/server/src/builder.test-d.ts +++ b/packages/server/src/builder.test-d.ts @@ -1,18 +1,23 @@ import type { MergedErrorMap, MetaPlugin, ORPCErrorConstructorMap, Schema } from '@orpc/contract' import type { IntersectPick } from '@orpc/shared' -import type { Builder, DefaultInitialContext } from './builder' -import type { BuilderWithInput, BuilderWithMiddlewares, BuilderWithOutput } from './builder-variants' -import type { MergedContext, MergedInitialContext } from './context' -import type { Lazy } from './lazy' -import type { Middleware, MiddlewareDone } from './middleware' -import type { DecoratedMiddleware } from './middleware-decorated' -import type { Procedure } from './procedure' -import type { DecoratedProcedure } from './procedure-decorated' -import type { AugmentedRouter } from './router-utils' import { onError, onFinish, onStart, onSuccess } from '@orpc/shared' import { expectTypeOf } from 'vitest' import { z } from 'zod' -import { os } from './builder' + +import type { + BuilderWithInput, + BuilderWithMiddlewares, + BuilderWithOutput, +} from './builder-variants.ts' +import type { Builder, DefaultInitialContext } from './builder.ts' +import { os } from './builder.ts' +import type { MergedContext, MergedInitialContext } from './context.ts' +import type { Lazy } from './lazy.ts' +import type { DecoratedMiddleware } from './middleware-decorated.ts' +import type { Middleware, MiddlewareDone } from './middleware.ts' +import type { DecoratedProcedure } from './procedure-decorated.ts' +import type { Procedure } from './procedure.ts' +import type { AugmentedRouter } from './router-utils.ts' const errorMap = { BASE: { @@ -24,8 +29,8 @@ const errorMap = { const builder = {} as Builder<{ auth: boolean }, typeof errorMap> // 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}`) }) +const schema1 = z.object({ schema1: z.number().transform((n) => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform((n) => `${n}`) }) describe('Builder', () => { it('$context', () => { @@ -38,19 +43,24 @@ describe('Builder', () => { }) it('$config', () => { - expectTypeOf(builder.$config({ disableInputValidation: true, disableOutputValidation: true })).toEqualTypeOf< - typeof builder - >() + expectTypeOf( + builder.$config({ disableInputValidation: true, disableOutputValidation: true }), + ).toEqualTypeOf() // @ts-expect-error - invalid setting builder.$config('invalid') }) it('.errors', () => { - expectTypeOf(builder.errors({ INVALID: { message: 'invalid' }, OVERRIDE: { message: 'override' } })).toEqualTypeOf< + expectTypeOf( + builder.errors({ INVALID: { message: 'invalid' }, OVERRIDE: { message: 'override' } }), + ).toEqualTypeOf< Builder< { auth: boolean }, - MergedErrorMap + MergedErrorMap< + typeof errorMap, + { INVALID: { message: string }; OVERRIDE: { message: string } } + > > >() @@ -78,11 +88,7 @@ describe('Builder', () => { }) expectTypeOf(decorated).toEqualTypeOf< - BuilderWithMiddlewares< - { auth: boolean }, - { extra: boolean }, - typeof errorMap - > + BuilderWithMiddlewares<{ auth: boolean }, { extra: boolean }, typeof errorMap> >() // @ts-expect-error - input is invalid @@ -102,18 +108,19 @@ describe('Builder', () => { }) it('keeps union and index-signature context', () => { - void os.$context<{ kind: 'a', a: string } | { kind: 'b', b: number }>() + void os + .$context<{ kind: 'a'; a: string } | { kind: 'b'; b: number }>() .use(({ next }) => next({ context: { user: 1 } })) .handler(({ context }) => { if (context.kind === 'a') { expectTypeOf(context.a).toEqualTypeOf() - } - else { + } else { expectTypeOf(context.b).toEqualTypeOf() } }) - void os.$context<{ [k: string]: unknown, db: string }>() + void os + .$context<{ [k: string]: unknown; db: string }>() .use(({ next }) => next({ context: { user: 1 } })) .handler(({ context }) => { expectTypeOf(context.db).toEqualTypeOf() @@ -121,11 +128,21 @@ describe('Builder', () => { }) it('outline middleware', () => { - const middleware = {} as Middleware<{ auth: boolean, g?: boolean }, { extra: boolean }, unknown, any, { SOME_ERROR: { message: string } }> + const middleware = {} as Middleware< + { auth: boolean; g?: boolean }, + { extra: boolean }, + unknown, + any, + { SOME_ERROR: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithMiddlewares< - MergedInitialContext<{ auth: boolean }, { extra: boolean }, { auth: boolean, g?: boolean }>, + MergedInitialContext< + { auth: boolean }, + { extra: boolean }, + { auth: boolean; g?: boolean } + >, { extra: boolean }, MergedErrorMap<{ SOME_ERROR: { message: string } }, typeof errorMap> > @@ -144,8 +161,14 @@ describe('Builder', () => { void builder.use({} as Middleware) }) - it('low-priority mid\'s errors and ignore conflicts', () => { - const middleware = {} as Middleware<{ auth: boolean }, object, unknown, any, { BASE: { message: 'CONFLICT' }, EXTRA: { message: string } }> + it("low-priority mid's errors and ignore conflicts", () => { + const middleware = {} as Middleware< + { auth: boolean }, + object, + unknown, + any, + { BASE: { message: 'CONFLICT' }; EXTRA: { message: string } } + > expectTypeOf(builder.use(middleware)).toEqualTypeOf< BuilderWithMiddlewares< @@ -161,21 +184,37 @@ describe('Builder', () => { it('pure', () => { // default expected TOutput should = any to ensure this middleware can be used in any procedure - expectTypeOf(builder.middleware(({ next, context, errors }, input, done) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(done).toEqualTypeOf>() - expectTypeOf(context).toEqualTypeOf>() - expectTypeOf(errors).toEqualTypeOf>() - - return next() - })).toEqualTypeOf< - DecoratedMiddleware, object, unknown, any, typeof errorMap> + expectTypeOf( + builder.middleware(({ next, context, errors }, input, done) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(done).toEqualTypeOf>() + expectTypeOf(context).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return next() + }), + ).toEqualTypeOf< + DecoratedMiddleware< + MergedContext<{ auth: boolean }, object>, + object, + unknown, + any, + typeof errorMap + > >() }) it('with TOutContext', () => { - expectTypeOf(builder.middleware(({ next }) => next({ context: { extra: true } }))).toEqualTypeOf< - DecoratedMiddleware, { extra: boolean }, unknown, any, typeof errorMap> + expectTypeOf( + builder.middleware(({ next }) => next({ context: { extra: true } })), + ).toEqualTypeOf< + DecoratedMiddleware< + MergedContext<{ auth: boolean }, object>, + { extra: boolean }, + unknown, + any, + typeof errorMap + > >() // @ts-expect-error - TOutContext is conflict with current context @@ -183,26 +222,39 @@ describe('Builder', () => { }) it('with expected input', () => { - expectTypeOf(builder.middleware(({ next }, input: { schema1: number }) => next({}))).toEqualTypeOf< - DecoratedMiddleware, object, { schema1: number }, any, typeof errorMap> + expectTypeOf( + builder.middleware(({ next }, input: { schema1: number }) => next({})), + ).toEqualTypeOf< + DecoratedMiddleware< + MergedContext<{ auth: boolean }, object>, + object, + { schema1: number }, + any, + typeof errorMap + > >() }) it('with expected output', () => { - expectTypeOf(builder.middleware(({ next }, input, done: MiddlewareDone<{ schema2: number }>) => next({}))).toEqualTypeOf< - DecoratedMiddleware, object, unknown, { schema2: number }, typeof errorMap> + expectTypeOf( + builder.middleware(({ next }, input, done: MiddlewareDone<{ schema2: number }>) => + next({}), + ), + ).toEqualTypeOf< + DecoratedMiddleware< + MergedContext<{ auth: boolean }, object>, + object, + unknown, + { schema2: number }, + typeof errorMap + > >() }) }) it('.input', () => { expectTypeOf(builder.input(schema1)).toEqualTypeOf< - BuilderWithInput< - { auth: boolean }, - object, - typeof schema1, - typeof errorMap - > + BuilderWithInput<{ auth: boolean }, object, typeof schema1, typeof errorMap> >() // @ts-expect-error - schema is invalid @@ -211,12 +263,7 @@ describe('Builder', () => { it('.output', () => { expectTypeOf(builder.output(schema2)).toEqualTypeOf< - BuilderWithOutput< - { auth: boolean }, - object, - typeof schema2, - typeof errorMap - > + BuilderWithOutput<{ auth: boolean }, object, typeof schema2, typeof errorMap> >() // @ts-expect-error - schema is invalid @@ -225,13 +272,15 @@ describe('Builder', () => { describe('.handler', () => { it('simple', () => { - expectTypeOf(builder.handler(async ({ errors, context }, input) => { - expectTypeOf(errors).toEqualTypeOf>() - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - - return 'out' - })).toEqualTypeOf< + expectTypeOf( + builder.handler(async ({ errors, context }, input) => { + expectTypeOf(errors).toEqualTypeOf>() + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() + + return 'out' + }), + ).toEqualTypeOf< DecoratedProcedure< { auth: boolean }, object, @@ -247,8 +296,20 @@ describe('Builder', () => { const router = { ping: {} as Procedure, // this builder has no middlewares, so it shouldn't restrict context - ping2: {} as Procedure<{ something1: boolean }, object, Schema, Schema, object>, - ping3: {} as Procedure<{ something2: boolean }, object, Schema, Schema, object>, + ping2: {} as Procedure< + { something1: boolean }, + object, + Schema, + Schema, + object + >, + ping3: {} as Procedure< + { something2: boolean }, + object, + Schema, + Schema, + object + >, } expectTypeOf(builder.router(router)).toEqualTypeOf< @@ -263,8 +324,20 @@ describe('Builder', () => { const router = { ping: {} as Procedure, // this builder has no middlewares, so it shouldn't restrict context - ping2: {} as Procedure<{ something1: boolean }, object, Schema, Schema, object>, - ping3: {} as Procedure<{ something2: boolean }, object, Schema, Schema, object>, + ping2: {} as Procedure< + { something1: boolean }, + object, + Schema, + Schema, + object + >, + ping3: {} as Procedure< + { something2: boolean }, + object, + Schema, + Schema, + object + >, } expectTypeOf(builder.lazy(async () => ({ default: router }))).toEqualTypeOf< @@ -278,9 +351,7 @@ describe('Builder', () => { describe('os', () => { it('is a builder', () => { - expectTypeOf(os).toEqualTypeOf< - Builder> - >() + expectTypeOf(os).toEqualTypeOf>>() }) it('can deal with "has no properties in common with type" in context', async () => { @@ -288,48 +359,46 @@ describe('os', () => { // If the builder's context and the procedure's context have no properties in common, // TypeScript may error with "has no properties in common". This can be resolved // by adding `& object` to the initial context (in .create and .$context). - void os.$context<{ context1: string }>().use(({ next }) => next({ context: { context11: true } })).router({ - ping: os.$context<{ context2?: number }>().handler(async () => 'out'), - nested: { - ping: os - .$context<{ context3?: boolean }>() - .input(z.object({})) - .output(z.string()) - .handler(async () => 'out'), - nested: os.$context<{ context4?: boolean }>().use(({ next }) => next()).lazy(async () => ({ - default: { - ping: os - .$context<{ context5?: Date }>() - .use(({ next }) => next()) - .output(z.string()) - .handler(async () => 'out'), - }, - })), - }, - }) + void os + .$context<{ context1: string }>() + .use(({ next }) => next({ context: { context11: true } })) + .router({ + ping: os.$context<{ context2?: number }>().handler(async () => 'out'), + nested: { + ping: os + .$context<{ context3?: boolean }>() + .input(z.object({})) + .output(z.string()) + .handler(async () => 'out'), + nested: os + .$context<{ context4?: boolean }>() + .use(({ next }) => next()) + .lazy(async () => ({ + default: { + ping: os + .$context<{ context5?: Date }>() + .use(({ next }) => next()) + .output(z.string()) + .handler(async () => 'out'), + }, + })), + }, + }) }) it('onStart, onSuccess, onError, onFinish can be used as a middleware', () => { type ExpectedBuilder = BuilderWithMiddlewares< - DefaultInitialContext & object, - IntersectPick, - Record + DefaultInitialContext & object, + IntersectPick, + Record > - expectTypeOf( - os.use(onStart(async () => {})), - ).toEqualTypeOf() + expectTypeOf(os.use(onStart(async () => {}))).toEqualTypeOf() - expectTypeOf( - os.use(onSuccess(async () => {})), - ).toEqualTypeOf() + expectTypeOf(os.use(onSuccess(async () => {}))).toEqualTypeOf() - expectTypeOf( - os.use(onError(async () => {})), - ).toEqualTypeOf() + expectTypeOf(os.use(onError(async () => {}))).toEqualTypeOf() - expectTypeOf( - os.use(onFinish(async () => {})), - ).toEqualTypeOf() + expectTypeOf(os.use(onFinish(async () => {}))).toEqualTypeOf() }) }) diff --git a/packages/server/src/builder.test.ts b/packages/server/src/builder.test.ts index 04bb3f9b3d..a70b1096cd 100644 --- a/packages/server/src/builder.test.ts +++ b/packages/server/src/builder.test.ts @@ -1,14 +1,15 @@ import type { AnyMetaPlugin, ErrorMap } from '@orpc/contract' -import type { AnyFunction } from '@orpc/shared' -import type { AnyMiddleware } from './middleware' import * as ContractModule from '@orpc/contract' +import type { AnyFunction } from '@orpc/shared' import { toArray } from '@orpc/shared' import { z } from 'zod' -import { Builder, os } from './builder' -import * as LazyModule from './lazy' -import * as MiddlewareDecoratedModule from './middleware-decorated' -import * as ProcedureDecoratedModule from './procedure-decorated' -import * as RouterUtilsModule from './router-utils' + +import { Builder, os } from './builder.ts' +import * as LazyModule from './lazy.ts' +import * as MiddlewareDecoratedModule from './middleware-decorated.ts' +import type { AnyMiddleware } from './middleware.ts' +import * as ProcedureDecoratedModule from './procedure-decorated.ts' +import * as RouterUtilsModule from './router-utils.ts' const resolveMetaPluginsSpy = vi.spyOn(ContractModule, 'resolveMetaPlugins') const mergeErrorMapSpy = vi.spyOn(ContractModule, 'mergeErrorMap') @@ -25,23 +26,23 @@ describe('builder', () => { errorMap: { BASE: { status: 400 }, }, - inputSchemas: [ - z.object({ init: z.string() }), - ], - outputSchemas: [ - z.object({ init: z.string() }), - ], + inputSchemas: [z.object({ init: z.string() })], + outputSchemas: [z.object({ init: z.string() })], meta: { base: true, }, metaPlugins: [ { name: 'test', - init: m => m, + init: (m) => m, }, ], orderedMiddlewares: [ - { middleware: vi.fn() as AnyFunction, inputSchemasLengthAtUse: 0, outputSchemasLengthAtUse: 0 }, + { + middleware: vi.fn() as AnyFunction, + inputSchemasLengthAtUse: 0, + outputSchemasLengthAtUse: 0, + }, ], disableInputValidation: false, disableOutputValidation: false, @@ -49,7 +50,7 @@ describe('builder', () => { const metaPlugin: AnyMetaPlugin = { name: 'test', - init: m => ({ ...m, metaPlugin: true }), + init: (m) => ({ ...m, metaPlugin: true }), } it('create', () => { @@ -109,10 +110,7 @@ describe('builder', () => { expect(applied).not.toBe(builder) expect(mergeErrorMapSpy).toHaveBeenCalledOnce() - expect(mergeErrorMapSpy).toHaveBeenCalledWith( - builder['~orpc'].errorMap, - errors, - ) + expect(mergeErrorMapSpy).toHaveBeenCalledWith(builder['~orpc'].errorMap, errors) expect(applied['~orpc']).toEqual({ ...builder['~orpc'], @@ -132,10 +130,7 @@ describe('builder', () => { expect(applied).not.toBe(builder) expect(mergeErrorMapSpy).toHaveBeenCalledOnce() - expect(mergeErrorMapSpy).toHaveBeenCalledWith( - builder['~orpc'].errorMap, - errors, - ) + expect(mergeErrorMapSpy).toHaveBeenCalledWith(builder['~orpc'].errorMap, errors) expect(resolveMetaPluginsSpy).toHaveBeenCalledOnce() expect(resolveMetaPluginsSpy).toHaveBeenCalledWith( @@ -161,10 +156,7 @@ describe('builder', () => { expect(applied).not.toBe(builder) expect(mergeErrorMapSpy).toHaveBeenCalledOnce() - expect(mergeErrorMapSpy).toHaveBeenCalledWith( - {}, - builder['~orpc'].errorMap, - ) + expect(mergeErrorMapSpy).toHaveBeenCalledWith({}, builder['~orpc'].errorMap) expect(applied['~orpc']).toEqual({ ...builder['~orpc'], @@ -255,13 +247,15 @@ describe('builder', () => { metaPlugins: builder['~orpc'].metaPlugins, }) - expect(decorateMiddlewareSpy).toHaveBeenCalledWith(builder['~orpc'].orderedMiddlewares[0]!.middleware) + expect(decorateMiddlewareSpy).toHaveBeenCalledWith( + builder['~orpc'].orderedMiddlewares[0]!.middleware, + ) expect(decorated.use).toHaveBeenCalledWith(middleware) }) it('with meta plugins', () => { const middleware: AnyMiddleware = vi.fn() as any - const midMetaPlugin: AnyMetaPlugin = { name: 'test', init: m => m } + const midMetaPlugin: AnyMetaPlugin = { name: 'test', init: (m) => m } middleware['~orpc'] = { metaPlugins: [midMetaPlugin], } diff --git a/packages/server/src/builder.ts b/packages/server/src/builder.ts index 5313932482..87cadd4c5d 100644 --- a/packages/server/src/builder.ts +++ b/packages/server/src/builder.ts @@ -1,18 +1,35 @@ -import type { AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, InitialInputSchema, InitialOutputSchema, MergedErrorMap, MetaPlugin, ORPCErrorConstructorMap, ProcedureContractDefinition, Schema } from '@orpc/contract' -import type { IntersectPick } from '@orpc/shared' -import type { BuilderWithInput, BuilderWithMiddlewares, BuilderWithOutput } from './builder-variants' -import type { Context, MergedInitialContext } from './context' -import type { Middleware } from './middleware' -import type { DecoratedMiddleware } from './middleware-decorated' -import type { OrderedMiddleware, ProcedureConfig, ProcedureHandler } from './procedure' -import type { AnyRouter } from './router' -import type { AugmentedRouter } from './router-utils' +import type { + AnySchema, + ErrorMap, + InferSchemaInput, + InferSchemaOutput, + InitialInputSchema, + InitialOutputSchema, + MergedErrorMap, + MetaPlugin, + ORPCErrorConstructorMap, + ProcedureContractDefinition, + Schema, +} from '@orpc/contract' import { getHiddenMetaPlugins, mergeErrorMap, resolveMetaPlugins } from '@orpc/contract' +import type { IntersectPick } from '@orpc/shared' import { toArray } from '@orpc/shared' -import { Lazy } from './lazy' -import { decorateMiddleware } from './middleware-decorated' -import { DecoratedProcedure } from './procedure-decorated' -import { augmentRouter } from './router-utils' + +import type { + BuilderWithInput, + BuilderWithMiddlewares, + BuilderWithOutput, +} from './builder-variants.ts' +import type { Context, MergedInitialContext } from './context.ts' +import { Lazy } from './lazy.ts' +import type { DecoratedMiddleware } from './middleware-decorated.ts' +import { decorateMiddleware } from './middleware-decorated.ts' +import type { Middleware } from './middleware.ts' +import { DecoratedProcedure } from './procedure-decorated.ts' +import type { OrderedMiddleware, ProcedureConfig, ProcedureHandler } from './procedure.ts' +import type { AugmentedRouter } from './router-utils.ts' +import { augmentRouter } from './router-utils.ts' +import type { AnyRouter } from './router.ts' /** * The initial context type used when `.$context` is not called. @@ -21,14 +38,14 @@ import { augmentRouter } from './router-utils' * * @see {@link https://orpc.dev/docs/context#default-initial-context | Context - Default Initial Context} */ -export interface DefaultInitialContext { -} +export interface DefaultInitialContext {} export interface BuilderDefinition< TInputSchema extends AnySchema, TInjectedContext extends AnySchema, TErrorMap extends ErrorMap, ->extends ProcedureContractDefinition, ProcedureConfig { +> + extends ProcedureContractDefinition, ProcedureConfig { orderedMiddlewares: OrderedMiddleware[] } @@ -38,17 +55,16 @@ export interface BuilderDefinition< * * @see {@link https://orpc.dev/docs/procedure | Procedure} */ -export class Builder< - TInitialContext extends Context, - TErrorMap extends ErrorMap, -> { +export class Builder { '~orpc': BuilderDefinition /** * Private constructor to prevent direct instantiation. * Use the static `create` method to initialize a new instance with a safe initial definition. */ - private constructor(definition: BuilderDefinition) { + private 'constructor'( + definition: BuilderDefinition, + ) { this['~orpc'] = definition } @@ -58,7 +74,10 @@ export class Builder< * * @see {@link https://orpc.dev/docs/procedure | Procedure} */ - static create(): Builder> { + static 'create'(): Builder< + T & object, + Record + > { // Using `& object` avoids "has no properties in common with type" errors // when combining procedures or routers with compatible but non-overlapping contexts. @@ -75,7 +94,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/context#initial-context | Context - Initial Context} */ - $context(): Builder { + '$context'(): Builder { // Using `& object` avoids "has no properties in common with type" errors // when combining procedures or routers with compatible but non-overlapping contexts. @@ -89,7 +108,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/recipes/validation-customization | Validation Customization} */ - $config(config: ProcedureConfig): Builder { + '$config'(config: ProcedureConfig): Builder { return new Builder({ ...this['~orpc'], ...config, @@ -101,7 +120,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/metadata | Metadata} */ - meta( + 'meta'( ...plugins: MetaPlugin[] ): Builder { const [meta, metaPlugins] = resolveMetaPlugins( @@ -123,9 +142,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/error-handling#typesafe-errors | Error Handling - Typesafe Errors} */ - errors( - errors: U, - ): Builder> { + 'errors'(errors: U): Builder> { let builder = new Builder({ ...this['~orpc'], errorMap: mergeErrorMap(this['~orpc'].errorMap, errors), @@ -145,7 +162,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/middleware | Middleware} */ - use< + 'use'< $OutContext extends IntersectPick, $InContext extends Context = TInitialContext, $ErrorMap extends ErrorMap = TErrorMap, @@ -165,11 +182,14 @@ export class Builder< let builder = new Builder({ ...this['~orpc'], errorMap: mergeErrorMap(middleware['~orpc']?.errorMap ?? {}, this['~orpc'].errorMap), - orderedMiddlewares: [...this['~orpc'].orderedMiddlewares, { - middleware, - inputSchemasLengthAtUse: this['~orpc'].inputSchemas?.length, - outputSchemasLengthAtUse: this['~orpc'].outputSchemas?.length, - }], + orderedMiddlewares: [ + ...this['~orpc'].orderedMiddlewares, + { + middleware, + inputSchemasLengthAtUse: this['~orpc'].inputSchemas?.length, + outputSchemasLengthAtUse: this['~orpc'].outputSchemas?.length, + }, + ], }) if (middleware['~orpc']?.metaPlugins) { @@ -185,7 +205,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/middleware | Middleware} */ - middleware< + 'middleware'< $OutContext extends IntersectPick, $Input, $Output = any, // $Output = any by default is important to make middleware can be used in any output by default @@ -220,7 +240,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/procedure#inputoutput-validation | Procedure - Input/Output Validation} */ - input<$ extends AnySchema>(schema: $): BuilderWithInput { + 'input'<$ extends AnySchema>(schema: $): BuilderWithInput { let builder = new Builder({ ...this['~orpc'], inputSchemas: [...toArray(this['~orpc'].inputSchemas), schema], @@ -239,7 +259,9 @@ export class Builder< * * @see {@link https://orpc.dev/docs/procedure#inputoutput-validation | Procedure - Input/Output Validation} */ - output<$ extends AnySchema>(schema: $): BuilderWithOutput { + 'output'<$ extends AnySchema>( + schema: $, + ): BuilderWithOutput { let builder = new Builder({ ...this['~orpc'], outputSchemas: [...toArray(this['~orpc'].outputSchemas), schema], @@ -259,15 +281,14 @@ export class Builder< * * @see {@link https://orpc.dev/docs/procedure | Procedure} */ - handler( - handler: ProcedureHandler, T, ORPCErrorConstructorMap>, - ): DecoratedProcedure< - TInitialContext, - object, - InitialInputSchema, - Schema, - TErrorMap - > { + 'handler'( + handler: ProcedureHandler< + TInitialContext, + InferSchemaOutput, + T, + ORPCErrorConstructorMap + >, + ): DecoratedProcedure, TErrorMap> { let procedure = new DecoratedProcedure({ ...this['~orpc'], handler, @@ -287,9 +308,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/router#extending-router | Router - Extending Router} */ - router( - router: T, - ): AugmentedRouter { + 'router'(router: T): AugmentedRouter { return augmentRouter(router, { ...this['~orpc'], middlewares: this['~orpc'].orderedMiddlewares.map(({ middleware }) => middleware), @@ -302,7 +321,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/router#lazy-router | Router - Lazy Router} */ - lazy( + 'lazy'( loader: () => Promise<{ default: T }>, ): Lazy> { return new Lazy({ @@ -324,4 +343,7 @@ export class Builder< * * @see {@link https://orpc.dev/docs/procedure | Procedure} */ -export const os: Builder> = /* @__PURE__ */ Builder.create() +export const os: Builder< + DefaultInitialContext & object, + Record +> = /* @__PURE__ */ Builder.create() diff --git a/packages/server/src/constants.test.ts b/packages/server/src/constants.test.ts index 99fb9060d2..4bd80d7e7a 100644 --- a/packages/server/src/constants.test.ts +++ b/packages/server/src/constants.test.ts @@ -1,5 +1,5 @@ it('exports DEFAULT_ERROR_STATUS_CODE', async () => { - await expect(import('./constants')).resolves.toMatchObject({ + await expect(import('./constants.ts')).resolves.toMatchObject({ DEFAULT_ERROR_STATUS: 400, DEFAULT_SUCCESS_STATUS: 200, }) diff --git a/packages/server/src/context.test-d.ts b/packages/server/src/context.test-d.ts index 70702581c6..2bd473abca 100644 --- a/packages/server/src/context.test-d.ts +++ b/packages/server/src/context.test-d.ts @@ -1,39 +1,68 @@ -import type { MergedContext, MergedInitialContext } from './context' import { expectTypeOf, it } from 'vitest' +import type { MergedContext, MergedInitialContext } from './context.ts' + describe('MergedInitialContext', () => { it('no extra', () => { - expectTypeOf>().toEqualTypeOf<{ db: string }>() - expectTypeOf>().toEqualTypeOf<{ db: string }>() - expectTypeOf>().toEqualTypeOf<{ db: string }>() + expectTypeOf< + MergedInitialContext<{ db: string }, { user: number }, { db: string; user: number }> + >().toEqualTypeOf<{ db: string }>() + expectTypeOf< + MergedInitialContext<{ db: string }, { user: number }, { db: string }> + >().toEqualTypeOf<{ db: string }>() + expectTypeOf< + MergedInitialContext<{ db: string }, { user: number }, { user: number }> + >().toEqualTypeOf<{ db: string }>() }) it('with extra', () => { - expectTypeOf>().toEqualTypeOf<{ db: string } & { user: number }>() - expectTypeOf>().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf<{ db: string } & { extra: boolean }>() + expectTypeOf< + MergedInitialContext<{ db: string }, object, { db: string; user: number }> + >().toEqualTypeOf<{ db: string } & { user: number }>() + expectTypeOf>().toEqualTypeOf< + object & { db: string } + >() + expectTypeOf< + MergedInitialContext<{ db: string }, { user: number }, { extra: boolean }> + >().toEqualTypeOf<{ db: string } & { extra: boolean }>() }) it('conflict keys (type check is not performed here, just key check)', () => { - expectTypeOf>().toEqualTypeOf<{ db: string }>() + expectTypeOf>().toEqualTypeOf<{ + db: string + }>() }) it('keeps unions and known keys of index signatures', () => { - expectTypeOf>().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf() + expectTypeOf< + MergedInitialContext + >().toEqualTypeOf() + expectTypeOf< + MergedInitialContext + >().toEqualTypeOf() + expectTypeOf< + MergedInitialContext + >().toEqualTypeOf() }) }) describe('MergedContext', () => { it('merges new context', () => { - expectTypeOf>().toEqualTypeOf<{ db: string } & { user: number }>() - expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf< + { db: string } & { user: number } + >() + expectTypeOf>().toEqualTypeOf< + object & { user: number } + >() }) it('overrides existing context', () => { - expectTypeOf>().toEqualTypeOf<{ db: string } & { user: number }>() - expectTypeOf>().toEqualTypeOf & { user: number }>() + expectTypeOf>().toEqualTypeOf< + { db: string } & { user: number } + >() + expectTypeOf>().toEqualTypeOf< + Omit & { user: number } + >() }) it('returns current if no new context', () => { @@ -41,7 +70,14 @@ describe('MergedContext', () => { }) it('keeps unions and known keys of index signatures', () => { - expectTypeOf>().toEqualTypeOf<({ kind: 'a', a: string } | { kind: 'b' }) & { user: number }>() - expectTypeOf>().toEqualTypeOf<{ [k: string]: unknown, db: string } & { user: number }>() + expectTypeOf< + MergedContext< + { kind: 'a'; a: string; user: string } | { kind: 'b'; user: string }, + { user: number } + > + >().toEqualTypeOf<({ kind: 'a'; a: string } | { kind: 'b' }) & { user: number }>() + expectTypeOf< + MergedContext<{ [k: string]: unknown; db: string }, { user: number }> + >().toEqualTypeOf<{ [k: string]: unknown; db: string } & { user: number }>() }) }) diff --git a/packages/server/src/extensions/callable.test-d.ts b/packages/server/src/extensions/callable.test-d.ts index 8676ef231b..bb30fa505a 100644 --- a/packages/server/src/extensions/callable.test-d.ts +++ b/packages/server/src/extensions/callable.test-d.ts @@ -1,6 +1,7 @@ import type { DecoratedProcedure, Procedure, ProcedureClient } from '@orpc/server' import { z } from 'zod' -import './callable' + +import './callable.ts' const errorMap = { BASE: { @@ -9,8 +10,8 @@ const errorMap = { }, } -const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) -const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +const schema1 = z.object({ schema1: z.number().transform((n) => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform((n) => `${n}`) }) it('adds .callable method to DecoratedProcedure', async () => { const procedure = {} as DecoratedProcedure< @@ -21,20 +22,17 @@ it('adds .callable method to DecoratedProcedure', async () => { typeof errorMap > - expectTypeOf(procedure.callable({ context: (cx: { cache?: boolean }) => ({ auth: true }) })).toEqualTypeOf< - & ProcedureClient< - { cache?: boolean }, - typeof schema1, - typeof schema2, - typeof errorMap - > - & Procedure< - { auth: boolean }, - { extra: string }, + expectTypeOf( + procedure.callable({ context: (cx: { cache?: boolean }) => ({ auth: true }) }), + ).toEqualTypeOf< + ProcedureClient<{ cache?: boolean }, typeof schema1, typeof schema2, typeof errorMap> & + Procedure< + { auth: boolean }, + { extra: string }, typeof schema1, typeof schema2, typeof errorMap - > + > >() // @ts-expect-error - invalid initial context diff --git a/packages/server/src/extensions/callable.test.ts b/packages/server/src/extensions/callable.test.ts index 92cbcee657..510788a864 100644 --- a/packages/server/src/extensions/callable.test.ts +++ b/packages/server/src/extensions/callable.test.ts @@ -1,6 +1,6 @@ -import { os } from '../builder' -import { Procedure } from '../procedure' -import './callable' +import { os } from '../builder.ts' +import { Procedure } from '../procedure.ts' +import './callable.ts' it('adds .callable into decorated procedure', async () => { const callable = os diff --git a/packages/server/src/extensions/callable.ts b/packages/server/src/extensions/callable.ts index 52352ad475..b097850269 100644 --- a/packages/server/src/extensions/callable.ts +++ b/packages/server/src/extensions/callable.ts @@ -1,11 +1,12 @@ import type { ClientContext } from '@orpc/client' import type { AnySchema, ErrorMap } from '@orpc/contract' import type { MaybeOptionalOptions } from '@orpc/shared' -import type { Context } from '../context' -import type { Procedure } from '../procedure' -import type { ProcedureClient, ProcedureClientOptions } from '../procedure-client' -import { createProcedureClient } from '../procedure-client' -import { DecoratedProcedure } from '../procedure-decorated' + +import type { Context } from '../context.ts' +import type { ProcedureClient, ProcedureClientOptions } from '../procedure-client.ts' +import { createProcedureClient } from '../procedure-client.ts' +import { DecoratedProcedure } from '../procedure-decorated.ts' +import type { Procedure } from '../procedure.ts' declare module '@orpc/server' { interface DecoratedProcedure< @@ -17,15 +18,10 @@ declare module '@orpc/server' { > { callable( ...rest: MaybeOptionalOptions< - ProcedureClientOptions< - TInitialContext, - TOutputSchema, - TErrorMap, - TClientContext - > + ProcedureClientOptions > - ): Procedure - & ProcedureClient + ): Procedure & + ProcedureClient } } diff --git a/packages/server/src/helpers/base64url.test.ts b/packages/server/src/helpers/base64url.test.ts index edd3420c53..2ac1da6963 100644 --- a/packages/server/src/helpers/base64url.test.ts +++ b/packages/server/src/helpers/base64url.test.ts @@ -1,4 +1,4 @@ -import { decodeBase64url, encodeBase64url } from './base64url' +import { decodeBase64url, encodeBase64url } from './base64url.ts' describe('encodeBase64url / decodeBase64url', () => { it('should encode and decode Uint8Array correctly', () => { @@ -59,7 +59,7 @@ describe('decodeBase64url canonical form', () => { it('rejects non-canonical and non-base64url input', () => { expect(decodeBase64url('')).toEqual(new Uint8Array([])) expect(decodeBase64url('QQ')).toEqual(new Uint8Array([0x41])) - expect(decodeBase64url('-_-_')).toEqual(new Uint8Array([0xFB, 0xFF, 0xBF])) + expect(decodeBase64url('-_-_')).toEqual(new Uint8Array([0xfb, 0xff, 0xbf])) for (const input of ['QR', 'QS', 'QT', 'QQ=', 'QQ==', ' QQ ', 'QQ\n\n', '+/+/', 'A', 'AAAAA']) { expect(decodeBase64url(input), JSON.stringify(input)).toBeUndefined() diff --git a/packages/server/src/helpers/base64url.ts b/packages/server/src/helpers/base64url.ts index ec5bdcba46..5f2aa51a22 100644 --- a/packages/server/src/helpers/base64url.ts +++ b/packages/server/src/helpers/base64url.ts @@ -15,10 +15,7 @@ import { encodeBase64 } from '@orpc/shared' * @see {@link https://orpc.dev/docs/helpers/base64url | Base64Url Helpers} */ export function encodeBase64url(data: Uint8Array): string { - return encodeBase64(data) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, '') + return encodeBase64(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') } /** @@ -36,7 +33,9 @@ export function encodeBase64url(data: Uint8Array): string { * * @see {@link https://orpc.dev/docs/helpers/base64url | Base64Url Helpers} */ -export function decodeBase64url(base64url: string | undefined | null): Uint8Array | undefined { +export function decodeBase64url( + base64url: string | undefined | null, +): Uint8Array | undefined { try { if (typeof base64url !== 'string') { return undefined @@ -56,8 +55,7 @@ export function decodeBase64url(base64url: string | undefined | null): Uint8Arra } return encodeBase64url(bytes) === base64url ? bytes : undefined - } - catch { + } catch { return undefined } } diff --git a/packages/server/src/helpers/cookie.test.ts b/packages/server/src/helpers/cookie.test.ts index e58f843933..5ebfa9fb80 100644 --- a/packages/server/src/helpers/cookie.test.ts +++ b/packages/server/src/helpers/cookie.test.ts @@ -1,4 +1,4 @@ -import { deleteCookie, getCookie, setCookie } from './cookie' +import { deleteCookie, getCookie, setCookie } from './cookie.ts' describe('setCookie', () => { it('should work with Headers object', () => { @@ -65,7 +65,7 @@ describe('setCookie', () => { it('should support custom encode function', () => { const headers = new Headers() setCookie(headers, 'test', 'value with spaces', { - encode: str => str.replace(/ /g, '_'), + encode: (str) => str.replace(/ /g, '_'), }) expect(headers.get('Set-Cookie')).toContain('test=value_with_spaces') @@ -110,9 +110,11 @@ describe('getCookie', () => { expect(getCookie(headers, 'test')).toBe('"quoted value"') // With custom decode option that removes quotes - expect(getCookie(headers, 'test', { - decode: (val: string) => val.replace(/^"|"$/g, ''), - })).toBe('quoted value') + expect( + getCookie(headers, 'test', { + decode: (val: string) => val.replace(/^"|"$/g, ''), + }), + ).toBe('quoted value') }) it('should handle invalid cookie formats gracefully', () => { diff --git a/packages/server/src/helpers/cookie.ts b/packages/server/src/helpers/cookie.ts index 72ee98b42a..9bf0937f02 100644 --- a/packages/server/src/helpers/cookie.ts +++ b/packages/server/src/helpers/cookie.ts @@ -36,15 +36,18 @@ export function setCookie( return } - const cookieString = stringifySetCookie({ - // Force path to '/' by default so the cookie is available across the - // entire app, not just under the directory of the request that set it - // (which is what browsers do per RFC 6265 §5.1.4 when Path is omitted). - path: '/', - ...options, - name, // prioritize - value, // prioritize - }, options) + const cookieString = stringifySetCookie( + { + // Force path to '/' by default so the cookie is available across the + // entire app, not just under the directory of the request that set it + // (which is what browsers do per RFC 6265 §5.1.4 when Path is omitted). + path: '/', + ...options, + name, // prioritize + value, // prioritize + }, + options, + ) headers.append('Set-Cookie', cookieString) } diff --git a/packages/server/src/helpers/encryption.test.ts b/packages/server/src/helpers/encryption.test.ts index 5dc66a538e..6ede520a01 100644 --- a/packages/server/src/helpers/encryption.test.ts +++ b/packages/server/src/helpers/encryption.test.ts @@ -1,4 +1,4 @@ -import { decrypt, encrypt } from './encryption' +import { decrypt, encrypt } from './encryption.ts' describe('encrypt/decrypt', () => { const secret = 'test-secret-key' @@ -75,7 +75,8 @@ describe('encrypt/decrypt', () => { { value: 'compatibility-value', secret: 'compatibility-encryption-secret', - encrypted: 'hbSNaYOIGOVnapa2UgZgzHgNr6ARf1zWk-3cNWhDBwsFOnQ9S4IZR5uUdqIM-WbTRAfryalkWnHJD8voMh5y', + encrypted: + 'hbSNaYOIGOVnapa2UgZgzHgNr6ARf1zWk-3cNWhDBwsFOnQ9S4IZR5uUdqIM-WbTRAfryalkWnHJD8voMh5y', }, { value: '', @@ -85,7 +86,8 @@ describe('encrypt/decrypt', () => { { value: 'value.with.dots-🚀-中文', secret: 'compatibility-encryption-secret', - encrypted: 'xLe6faWGA5J3MUy-Cue5_mZXiJVlrzqKho8ywwhlCHbO4koT4XT3MxzesdTMLxZIAS6pPJLfU4qKKcZHwOwAAU5RHP-sMp8', + encrypted: + 'xLe6faWGA5J3MUy-Cue5_mZXiJVlrzqKho8ywwhlCHbO4koT4XT3MxzesdTMLxZIAS6pPJLfU4qKKcZHwOwAAU5RHP-sMp8', }, ] as const diff --git a/packages/server/src/helpers/encryption.ts b/packages/server/src/helpers/encryption.ts index 057a5ed8d7..5f05c08a0b 100644 --- a/packages/server/src/helpers/encryption.ts +++ b/packages/server/src/helpers/encryption.ts @@ -1,4 +1,4 @@ -import { decodeBase64url, encodeBase64url } from './base64url' +import { decodeBase64url, encodeBase64url } from './base64url.ts' const PBKDF2_CONFIG = { name: 'PBKDF2', @@ -57,11 +57,7 @@ export async function encrypt(value: string, secret: string): Promise { const iv = crypto.getRandomValues(new Uint8Array(CRYPTO_CONSTANTS.IV_LENGTH)) - const encrypted = await crypto.subtle.encrypt( - { name: AES_GCM_CONFIG.name, iv }, - key, - data, - ) + const encrypted = await crypto.subtle.encrypt({ name: AES_GCM_CONFIG.name, iv }, key, data) const result = new Uint8Array(salt.length + iv.length + encrypted.byteLength) result.set(salt, 0) @@ -84,7 +80,10 @@ export async function encrypt(value: string, secret: string): Promise { * * @see {@link https://orpc.dev/docs/helpers/encryption | Encryption Helpers} */ -export async function decrypt(encrypted: string | undefined | null, secret: string): Promise { +export async function decrypt( + encrypted: string | undefined | null, + secret: string, +): Promise { try { const data = decodeBase64url(encrypted) @@ -96,7 +95,10 @@ export async function decrypt(encrypted: string | undefined | null, secret: stri const decoder = new TextDecoder() const salt = data.slice(0, CRYPTO_CONSTANTS.SALT_LENGTH) - const iv = data.slice(CRYPTO_CONSTANTS.SALT_LENGTH, CRYPTO_CONSTANTS.SALT_LENGTH + CRYPTO_CONSTANTS.IV_LENGTH) + const iv = data.slice( + CRYPTO_CONSTANTS.SALT_LENGTH, + CRYPTO_CONSTANTS.SALT_LENGTH + CRYPTO_CONSTANTS.IV_LENGTH, + ) const encryptedData = data.slice(CRYPTO_CONSTANTS.SALT_LENGTH + CRYPTO_CONSTANTS.IV_LENGTH) const keyMaterial = await crypto.subtle.importKey( @@ -122,8 +124,7 @@ export async function decrypt(encrypted: string | undefined | null, secret: stri ) return decoder.decode(decrypted) - } - catch { + } catch { // Return undefined if decryption fails (invalid key, corrupted data, etc.) return undefined } diff --git a/packages/server/src/helpers/index.test.ts b/packages/server/src/helpers/index.test.ts index 58cbd3064b..3251ee2383 100644 --- a/packages/server/src/helpers/index.test.ts +++ b/packages/server/src/helpers/index.test.ts @@ -1,5 +1,5 @@ it('exports helpers', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ encodeBase64url: expect.any(Function), setCookie: expect.any(Function), encrypt: expect.any(Function), diff --git a/packages/server/src/helpers/index.ts b/packages/server/src/helpers/index.ts index 79fbe74a2b..2269f33bc8 100644 --- a/packages/server/src/helpers/index.ts +++ b/packages/server/src/helpers/index.ts @@ -1,4 +1,4 @@ -export * from './base64url' -export * from './cookie' -export * from './encryption' -export * from './signing' +export * from './base64url.ts' +export * from './cookie.ts' +export * from './encryption.ts' +export * from './signing.ts' diff --git a/packages/server/src/helpers/signing.test.ts b/packages/server/src/helpers/signing.test.ts index 46042da85e..ce0a83c3c3 100644 --- a/packages/server/src/helpers/signing.test.ts +++ b/packages/server/src/helpers/signing.test.ts @@ -1,5 +1,5 @@ -import { getCookie } from './cookie' -import { getSignedValue, sign, unsign } from './signing' +import { getCookie } from './cookie.ts' +import { getSignedValue, sign, unsign } from './signing.ts' describe('signing', () => { const secret = 'test-secret-key' @@ -220,8 +220,7 @@ describe('signing', () => { if (i === j) { expect(unsignedValue).toBe(value) - } - else { + } else { expect(unsignedValue).toBeUndefined() } } @@ -239,10 +238,10 @@ describe('signing', () => { // All signed values should be identical const firstSigned = results[0]!.signed - expect(results.every(r => r.signed === firstSigned)).toBe(true) + expect(results.every((r) => r.signed === firstSigned)).toBe(true) // All unsigned values should be identical and match original - expect(results.every(r => r.unsigned === value)).toBe(true) + expect(results.every((r) => r.unsigned === value)).toBe(true) }) }) @@ -289,7 +288,10 @@ describe('unsign canonical form', () => { 'user123.oneQsU0r5dvwQFHFEjjV1uOI/IR3gZfkYHij3TRauVA', // standard-base64 char 'user123.oneQsU0r5dvw\nQFHFEjjV1uOI_IR3gZfkYHij3TRauVA', // whitespace // percent-encoded whitespace becomes a real space after getCookie - getCookie(new Headers({ cookie: 'sid=user123.oneQsU0r5dvw%20QFHFEjjV1uOI_IR3gZfkYHij3TRauVA' }), 'sid'), + getCookie( + new Headers({ cookie: 'sid=user123.oneQsU0r5dvw%20QFHFEjjV1uOI_IR3gZfkYHij3TRauVA' }), + 'sid', + ), ] for (const variant of variants) { diff --git a/packages/server/src/helpers/signing.ts b/packages/server/src/helpers/signing.ts index 79e865b33e..b33b265b28 100644 --- a/packages/server/src/helpers/signing.ts +++ b/packages/server/src/helpers/signing.ts @@ -1,4 +1,4 @@ -import { decodeBase64url, encodeBase64url } from './base64url' +import { decodeBase64url, encodeBase64url } from './base64url.ts' const ALGORITHM = { name: 'HMAC', hash: 'SHA-256' } @@ -20,19 +20,11 @@ const ALGORITHM = { name: 'HMAC', hash: 'SHA-256' } export async function sign(value: string, secret: string): Promise { const encoder = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - ALGORITHM, - false, - ['sign'], - ) + const key = await crypto.subtle.importKey('raw', encoder.encode(secret), ALGORITHM, false, [ + 'sign', + ]) - const signature = await crypto.subtle.sign( - ALGORITHM, - key, - encoder.encode(value), - ) + const signature = await crypto.subtle.sign(ALGORITHM, key, encoder.encode(value)) return `${value}.${encodeBase64url(new Uint8Array(signature))}` } @@ -53,7 +45,10 @@ export async function sign(value: string, secret: string): Promise { * * @see {@link https://orpc.dev/docs/helpers/signing | Signing Helpers} */ -export async function unsign(signedValue: string | undefined | null, secret: string): Promise { +export async function unsign( + signedValue: string | undefined | null, + secret: string, +): Promise { if (typeof signedValue !== 'string') { return undefined } @@ -73,20 +68,11 @@ export async function unsign(signedValue: string | undefined | null, secret: str const encoder = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - ALGORITHM, - false, - ['verify'], - ) - - const isValid = await crypto.subtle.verify( - ALGORITHM, - key, - signature, - encoder.encode(value), - ) + const key = await crypto.subtle.importKey('raw', encoder.encode(secret), ALGORITHM, false, [ + 'verify', + ]) + + const isValid = await crypto.subtle.verify(ALGORITHM, key, signature, encoder.encode(value)) return isValid ? value : undefined } diff --git a/packages/server/src/implementer-procedure.test-d.ts b/packages/server/src/implementer-procedure.test-d.ts index c409475035..74cd9a8799 100644 --- a/packages/server/src/implementer-procedure.test-d.ts +++ b/packages/server/src/implementer-procedure.test-d.ts @@ -1,10 +1,11 @@ import type { ORPCErrorConstructorMap, Schema } from '@orpc/contract' -import type { MergedContext, MergedInitialContext } from './context' -import type { ImplementedProcedure, ProcedureImplementer } from './implementer-procedure' -import type { Middleware, MiddlewareDone } from './middleware' import { expectTypeOf } from 'vitest' import { z } from 'zod' +import type { MergedContext, MergedInitialContext } from './context.ts' +import type { ImplementedProcedure, ProcedureImplementer } from './implementer-procedure.ts' +import type { Middleware, MiddlewareDone } from './middleware.ts' + const errorMap = { BASE: { data: z.object({ id: z.string() }), @@ -61,7 +62,7 @@ describe('ProcedureImplementer', () => { it('outline middleware', () => { const middleware = {} as Middleware< - { auth: boolean, user: string, g?: boolean }, + { auth: boolean; user: string; g?: boolean }, { extra: boolean }, string, string, @@ -70,7 +71,11 @@ describe('ProcedureImplementer', () => { expectTypeOf(implementer.use(middleware)).toEqualTypeOf< ProcedureImplementer< - MergedInitialContext<{ auth: boolean }, { user: string }, { auth: boolean, user: string, g?: boolean }>, + MergedInitialContext< + { auth: boolean }, + { user: string }, + { auth: boolean; user: string; g?: boolean } + >, MergedContext<{ user: string }, { extra: boolean }>, Schema, Schema, @@ -78,11 +83,15 @@ describe('ProcedureImplementer', () => { > >() - // @ts-expect-error - input is invalid - void implementer.use({} as Middleware<{ auth: boolean, user: string }, object, number, any, object>) + void implementer.use( + // @ts-expect-error - input is invalid + {} as Middleware<{ auth: boolean; user: string }, object, number, any, object>, + ) - // @ts-expect-error - output is invalid - void implementer.use({} as Middleware<{ auth: boolean, user: string }, object, string, number, object>) + void implementer.use( + // @ts-expect-error - output is invalid + {} as Middleware<{ auth: boolean; user: string }, object, string, number, object>, + ) // @ts-expect-error - TInContext is not satisfy expected void implementer.use({} as Middleware<{ something: string }, object, string, string, object>) @@ -111,7 +120,7 @@ describe('ProcedureImplementer', () => { { user: string }, Schema, Schema, - typeof errorMap + typeof errorMap > >() @@ -176,7 +185,7 @@ describe('ImplementedProcedure', () => { it('outline middleware', () => { const middleware = {} as Middleware< - { auth: boolean, user: string, g?: boolean }, + { auth: boolean; user: string; g?: boolean }, { extra: boolean }, string, string, @@ -185,7 +194,11 @@ describe('ImplementedProcedure', () => { expectTypeOf(procedure.use(middleware)).toEqualTypeOf< ImplementedProcedure< - MergedInitialContext<{ auth: boolean }, { user: string }, { auth: boolean, user: string, g?: boolean }>, + MergedInitialContext< + { auth: boolean }, + { user: string }, + { auth: boolean; user: string; g?: boolean } + >, MergedContext<{ user: string }, { extra: boolean }>, Schema, Schema, @@ -193,11 +206,15 @@ describe('ImplementedProcedure', () => { > >() - // @ts-expect-error - input is invalid - void procedure.use({} as Middleware<{ auth: boolean, user: string }, object, number, any, object>) + void procedure.use( + // @ts-expect-error - input is invalid + {} as Middleware<{ auth: boolean; user: string }, object, number, any, object>, + ) - // @ts-expect-error - output is invalid - void procedure.use({} as Middleware<{ auth: boolean, user: string }, object, string, number, object>) + void procedure.use( + // @ts-expect-error - output is invalid + {} as Middleware<{ auth: boolean; user: string }, object, string, number, object>, + ) // @ts-expect-error - TInContext is not satisfy expected void procedure.use({} as Middleware<{ something: string }, object, string, string, object>) diff --git a/packages/server/src/implementer-procedure.test.ts b/packages/server/src/implementer-procedure.test.ts index 2f5ea6a0de..3f4e22603a 100644 --- a/packages/server/src/implementer-procedure.test.ts +++ b/packages/server/src/implementer-procedure.test.ts @@ -1,14 +1,13 @@ import { z } from 'zod' -import { ImplementedProcedure, ProcedureImplementer } from './implementer-procedure' + +import { ImplementedProcedure, ProcedureImplementer } from './implementer-procedure.ts' describe('procedureImplementer', () => { const definition = { errorMap: { INTERNAL_SERVER_ERROR: { message: 'Internal Server Error' }, }, - orderedMiddlewares: [ - { middleware: vi.fn() }, - ], + orderedMiddlewares: [{ middleware: vi.fn() }], inputSchemas: [z.string()], outputSchemas: [z.number()], meta: { meta: true }, diff --git a/packages/server/src/implementer-procedure.ts b/packages/server/src/implementer-procedure.ts index 9b2d206efe..775fd60db9 100644 --- a/packages/server/src/implementer-procedure.ts +++ b/packages/server/src/implementer-procedure.ts @@ -1,10 +1,17 @@ -import type { AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, ORPCErrorConstructorMap } from '@orpc/contract' +import type { + AnySchema, + ErrorMap, + InferSchemaInput, + InferSchemaOutput, + ORPCErrorConstructorMap, +} from '@orpc/contract' import type { IntersectPick } from '@orpc/shared' -import type { BuilderDefinition } from './builder' -import type { Context, MergedContext, MergedInitialContext } from './context' -import type { Middleware } from './middleware' -import type { ProcedureHandler } from './procedure' -import { Procedure } from './procedure' + +import type { BuilderDefinition } from './builder.ts' +import type { Context, MergedContext, MergedInitialContext } from './context.ts' +import type { Middleware } from './middleware.ts' +import type { ProcedureHandler } from './procedure.ts' +import { Procedure } from './procedure.ts' export class ProcedureImplementer< TInitialContext extends Context, @@ -15,12 +22,15 @@ export class ProcedureImplementer< > { '~orpc': BuilderDefinition - constructor(definition: BuilderDefinition) { + 'constructor'(definition: BuilderDefinition) { this['~orpc'] = definition } 'use'< - $OutContext extends IntersectPick, $OutContext>, + $OutContext extends IntersectPick< + MergedContext, + $OutContext + >, $InContext extends Context = MergedContext, $ErrorMap extends ErrorMap = TErrorMap, >( @@ -40,11 +50,14 @@ export class ProcedureImplementer< > { return new ProcedureImplementer({ ...this['~orpc'], - orderedMiddlewares: [...this['~orpc'].orderedMiddlewares, { - middleware, - inputSchemasLengthAtUse: this['~orpc'].inputSchemas?.length, - outputSchemasLengthAtUse: this['~orpc'].outputSchemas?.length, - }], + orderedMiddlewares: [ + ...this['~orpc'].orderedMiddlewares, + { + middleware, + inputSchemasLengthAtUse: this['~orpc'].inputSchemas?.length, + outputSchemasLengthAtUse: this['~orpc'].outputSchemas?.length, + }, + ], }) } @@ -77,7 +90,10 @@ export class ImplementedProcedure< TErrorMap extends ErrorMap, > extends Procedure { use< - $OutContext extends IntersectPick, $OutContext>, + $OutContext extends IntersectPick< + MergedContext, + $OutContext + >, $InContext extends Context = MergedContext, $ErrorMap extends ErrorMap = TErrorMap, >( @@ -101,11 +117,14 @@ export class ImplementedProcedure< return new ImplementedProcedure({ ...this['~orpc'], - orderedMiddlewares: [...this['~orpc'].orderedMiddlewares, { - middleware, - inputSchemasLengthAtUse: this['~orpc'].inputSchemas?.length, - outputSchemasLengthAtUse: this['~orpc'].outputSchemas?.length, - }], + orderedMiddlewares: [ + ...this['~orpc'].orderedMiddlewares, + { + middleware, + inputSchemasLengthAtUse: this['~orpc'].inputSchemas?.length, + outputSchemasLengthAtUse: this['~orpc'].outputSchemas?.length, + }, + ], }) as any } } diff --git a/packages/server/src/implementer-router.test-d.ts b/packages/server/src/implementer-router.test-d.ts index 99b030f98f..778a451cdd 100644 --- a/packages/server/src/implementer-router.test-d.ts +++ b/packages/server/src/implementer-router.test-d.ts @@ -1,14 +1,25 @@ -import type { InferRouterContractErrorMap, ORPCErrorConstructorMap, ProcedureContract, Schema } from '@orpc/contract' -import type { MergedContext, MergedInitialContext } from './context' -import type { ProcedureImplementer } from './implementer-procedure' -import type { RouterImplementer, RouterImplementerWithMiddlewares, SharedRouterImplementer, SharedRouterImplementerWithMiddlewares } from './implementer-router' -import type { Middleware, MiddlewareDone } from './middleware' -import type { DecoratedMiddleware } from './middleware-decorated' -import type { Procedure } from './procedure' -import type { AugmentedRouterWithMiddlewares } from './router-utils' +import type { + InferRouterContractErrorMap, + ORPCErrorConstructorMap, + ProcedureContract, + Schema, +} from '@orpc/contract' import { expectTypeOf } from 'vitest' import { z } from 'zod' -import { Lazy } from './lazy' + +import type { MergedContext, MergedInitialContext } from './context.ts' +import type { ProcedureImplementer } from './implementer-procedure.ts' +import type { + RouterImplementer, + RouterImplementerWithMiddlewares, + SharedRouterImplementer, + SharedRouterImplementerWithMiddlewares, +} from './implementer-router.ts' +import { Lazy } from './lazy.ts' +import type { DecoratedMiddleware } from './middleware-decorated.ts' +import type { Middleware, MiddlewareDone } from './middleware.ts' +import type { Procedure } from './procedure.ts' +import type { AugmentedRouterWithMiddlewares } from './router-utils.ts' const errorMap = { BASE: { @@ -18,9 +29,9 @@ const errorMap = { } // 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.string().transform(n => Number(n)) }) -const schema3 = z.object({ schema3: z.boolean().transform(n => `${n}`) }) +const schema1 = z.object({ schema1: z.number().transform((n) => `${n}`) }) +const schema2 = z.object({ schema2: z.string().transform((n) => Number(n)) }) +const schema3 = z.object({ schema3: z.boolean().transform((n) => `${n}`) }) const contract = { ping: {} as ProcedureContract, @@ -47,7 +58,13 @@ describe('RouterImplementer', () => { describe('deep access', () => { it('normal', () => { expectTypeOf(implementer.ping).toEqualTypeOf< - ProcedureImplementer<{ auth: boolean }, object, typeof schema1, typeof schema2, typeof errorMap> + ProcedureImplementer< + { auth: boolean }, + object, + typeof schema1, + typeof schema2, + typeof errorMap + > >() expectTypeOf(implementer.nested).toEqualTypeOf< @@ -61,33 +78,33 @@ describe('RouterImplementer', () => { it('conflict procedure/router names with implementer methods', () => { expectTypeOf(implementer.use).toEqualTypeOf< - & SharedRouterImplementer['use'] - & ProcedureImplementer<{ auth: boolean }, object, typeof schema3, typeof schema3, object> + SharedRouterImplementer['use'] & + ProcedureImplementer<{ auth: boolean }, object, typeof schema3, typeof schema3, object> >() expectTypeOf(implementer.middleware).toEqualTypeOf< - & SharedRouterImplementer['middleware'] - & ProcedureImplementer<{ auth: boolean }, object, typeof schema1, typeof schema1, object> + SharedRouterImplementer['middleware'] & + ProcedureImplementer<{ auth: boolean }, object, typeof schema1, typeof schema1, object> >() expectTypeOf(implementer.router).toEqualTypeOf< - & SharedRouterImplementer['router'] - & RouterImplementer + SharedRouterImplementer['router'] & + RouterImplementer >() expectTypeOf(implementer.router.router).toEqualTypeOf< - & SharedRouterImplementer['router'] - & ProcedureImplementer<{ auth: boolean }, object, typeof schema2, typeof schema2, object> + SharedRouterImplementer['router'] & + ProcedureImplementer<{ auth: boolean }, object, typeof schema2, typeof schema2, object> >() expectTypeOf(implementer.lazy).toEqualTypeOf< - & SharedRouterImplementer['lazy'] - & RouterImplementer + SharedRouterImplementer['lazy'] & + RouterImplementer >() expectTypeOf(implementer.lazy.lazy).toEqualTypeOf< - & SharedRouterImplementer['lazy'] - & ProcedureImplementer<{ auth: boolean }, object, typeof schema2, typeof schema2, object> + SharedRouterImplementer['lazy'] & + ProcedureImplementer<{ auth: boolean }, object, typeof schema2, typeof schema2, object> >() }) }) @@ -97,7 +114,9 @@ describe('RouterImplementer', () => { const decorated = implementer.use(({ next, errors, context }, input, done) => { expectTypeOf(input).toEqualTypeOf() expectTypeOf(done).toEqualTypeOf>() - expectTypeOf(errors).toEqualTypeOf>>() + expectTypeOf(errors).toEqualTypeOf< + ORPCErrorConstructorMap> + >() expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() if ('BASE' in errors) { @@ -108,11 +127,7 @@ describe('RouterImplementer', () => { }) expectTypeOf(decorated).toEqualTypeOf< - RouterImplementerWithMiddlewares< - TContract, - { auth: boolean }, - { more: string } - > + RouterImplementerWithMiddlewares >() // @ts-expect-error - input is invalid @@ -126,12 +141,18 @@ describe('RouterImplementer', () => { }) it('outline middleware', () => { - const middleware = {} as Middleware<{ auth: boolean, g?: boolean }, { more: string }, unknown, any, { SOME_ERROR: { message: string } }> + const middleware = {} as Middleware< + { auth: boolean; g?: boolean }, + { more: string }, + unknown, + any, + { SOME_ERROR: { message: string } } + > expectTypeOf(implementer.use(middleware)).toEqualTypeOf< RouterImplementerWithMiddlewares< TContract, - MergedInitialContext<{ auth: boolean }, object, { auth: boolean, g?: boolean }>, + MergedInitialContext<{ auth: boolean }, object, { auth: boolean; g?: boolean }>, { more: string } > >() @@ -147,11 +168,7 @@ describe('RouterImplementer', () => { const decorated = implementer.nested.use(({ next }) => next({ context: { extra: true } })) expectTypeOf(decorated).toEqualTypeOf< - RouterImplementerWithMiddlewares< - TContract['nested'], - { auth: boolean }, - { extra: boolean } - > + RouterImplementerWithMiddlewares >() }) @@ -159,11 +176,7 @@ describe('RouterImplementer', () => { const decorated = implementer.use(({ next }) => next({ context: { more: true } })) expectTypeOf(decorated).toEqualTypeOf< - RouterImplementerWithMiddlewares< - TContract, - { auth: boolean }, - { more: boolean } - > + RouterImplementerWithMiddlewares >() }) }) @@ -176,7 +189,9 @@ describe('RouterImplementer', () => { expectTypeOf(input).toEqualTypeOf() expectTypeOf(done).toEqualTypeOf>() expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(errors).toEqualTypeOf>>() + expectTypeOf(errors).toEqualTypeOf< + ORPCErrorConstructorMap> + >() if ('BASE' in errors) { expectTypeOf(errors.BASE).toBeFunction() @@ -191,7 +206,9 @@ describe('RouterImplementer', () => { }) it('with TOutContext', () => { - expectTypeOf(implementer.middleware(({ next }) => next({ context: { more: 'yes' } }))).toEqualTypeOf< + expectTypeOf( + implementer.middleware(({ next }) => next({ context: { more: 'yes' } })), + ).toEqualTypeOf< DecoratedMiddleware<{ auth: boolean }, { more: string }, unknown, any, object> >() @@ -200,13 +217,19 @@ describe('RouterImplementer', () => { }) it('with expected input', () => { - expectTypeOf(implementer.middleware(({ next }, input: { schema1: number }) => next({}))).toEqualTypeOf< + expectTypeOf( + implementer.middleware(({ next }, input: { schema1: number }) => next({})), + ).toEqualTypeOf< DecoratedMiddleware<{ auth: boolean }, object, { schema1: number }, any, object> >() }) it('with expected output', () => { - expectTypeOf(implementer.middleware(({ next }, input, done: MiddlewareDone<{ schema2: number }>) => next({}))).toEqualTypeOf< + expectTypeOf( + implementer.middleware(({ next }, input, done: MiddlewareDone<{ schema2: number }>) => + next({}), + ), + ).toEqualTypeOf< DecoratedMiddleware<{ auth: boolean }, object, unknown, { schema2: number }, object> >() }) @@ -231,20 +254,53 @@ describe('RouterImplementer', () => { describe('.router', () => { it('works', () => { const router = { - ping: {} as Procedure<{ anything: boolean }, object, typeof schema1, typeof schema2, typeof errorMap>, + ping: {} as Procedure< + { anything: boolean }, + object, + typeof schema1, + typeof schema2, + typeof errorMap + >, nested: { - pong: {} as Procedure<{ anything: boolean, optional?: boolean }, { anything: boolean }, typeof schema2, typeof schema1, object>, + pong: {} as Procedure< + { anything: boolean; optional?: boolean }, + { anything: boolean }, + typeof schema2, + typeof schema1, + object + >, }, use: {} as Procedure<{ anything: boolean }, object, typeof schema3, typeof schema3, object>, - middleware: {} as Procedure<{ anything: boolean }, object, typeof schema1, typeof schema1, object>, + middleware: {} as Procedure< + { anything: boolean }, + object, + typeof schema1, + typeof schema1, + object + >, router: { - router: {} as Procedure<{ anything: boolean }, object, typeof schema2, typeof schema2, object>, + router: {} as Procedure< + { anything: boolean }, + object, + typeof schema2, + typeof schema2, + object + >, }, lazy: new Lazy({ - loader: () => Promise.resolve({ default: { - lazy: {} as Procedure<{ anything: boolean }, object, typeof schema2, typeof schema2, object>, - } }), + loader: () => + Promise.resolve({ + default: { + lazy: {} as Procedure< + { anything: boolean }, + object, + typeof schema2, + typeof schema2, + object + >, + }, + }), meta: {}, }), } @@ -255,11 +311,23 @@ describe('RouterImplementer', () => { implementer.router({ // @ts-expect-error - error map is conflict - ping: {} as Procedure<{ anything: boolean }, object, typeof schema1, typeof schema2, { BASE: { data: typeof schema1 } }>, + ping: {} as Procedure< + { anything: boolean }, + object, + typeof schema1, + typeof schema2, + { BASE: { data: typeof schema1 } } + >, nested: { // @ts-expect-error - input schema is conflict - pong: {} as Procedure<{ anything: boolean, optional?: boolean }, { anything: boolean }, Schema<'invalid'>, typeof schema1, object>, + pong: {} as Procedure< + { anything: boolean; optional?: boolean }, + { anything: boolean }, + Schema<'invalid'>, + typeof schema1, + object + >, }, // @ts-expect-error - output schema is conflict @@ -269,7 +337,7 @@ describe('RouterImplementer', () => { router: {}, // @ts-expect-error - missing procedures lazy: new Lazy({ - loader: () => Promise.resolve({ default: { } }), + loader: () => Promise.resolve({ default: {} }), meta: {}, }), }) @@ -277,7 +345,13 @@ describe('RouterImplementer', () => { it('deep access', () => { const router = { - pong: {} as Procedure<{ anything: boolean, something?: boolean }, { anything: boolean }, typeof schema2, typeof schema1, object>, + pong: {} as Procedure< + { anything: boolean; something?: boolean }, + { anything: boolean }, + typeof schema2, + typeof schema1, + object + >, } const implemented = implementer.nested.router(router) @@ -292,7 +366,13 @@ describe('RouterImplementer', () => { it('conflict method', () => { const router = { - router: {} as Procedure<{ anything: boolean }, object, typeof schema2, typeof schema2, object>, + router: {} as Procedure< + { anything: boolean }, + object, + typeof schema2, + typeof schema2, + object + >, } const implemented = implementer.router.router(router) @@ -301,7 +381,13 @@ describe('RouterImplementer', () => { implementer.router.router({ // @ts-expect-error - input schema is conflict - router: {} as Procedure<{ anything: boolean }, object, Schema<'invalid'>, typeof schema2, object>, + router: {} as Procedure< + { anything: boolean }, + object, + Schema<'invalid'>, + typeof schema2, + object + >, }) }) }) @@ -309,20 +395,53 @@ describe('RouterImplementer', () => { describe('.lazy', () => { it('works', () => { const router = { - ping: {} as Procedure<{ anything: boolean, extra: boolean }, object, typeof schema1, typeof schema2, typeof errorMap>, + ping: {} as Procedure< + { anything: boolean; extra: boolean }, + object, + typeof schema1, + typeof schema2, + typeof errorMap + >, nested: { - pong: {} as Procedure<{ anything: boolean, optional?: boolean }, { anything: boolean }, typeof schema2, typeof schema1, object>, + pong: {} as Procedure< + { anything: boolean; optional?: boolean }, + { anything: boolean }, + typeof schema2, + typeof schema1, + object + >, }, use: {} as Procedure<{ anything: boolean }, object, typeof schema3, typeof schema3, object>, - middleware: {} as Procedure<{ anything: boolean }, object, typeof schema1, typeof schema1, object>, + middleware: {} as Procedure< + { anything: boolean }, + object, + typeof schema1, + typeof schema1, + object + >, router: { - router: {} as Procedure<{ anything: boolean }, object, typeof schema2, typeof schema2, object>, + router: {} as Procedure< + { anything: boolean }, + object, + typeof schema2, + typeof schema2, + object + >, }, lazy: new Lazy({ - loader: () => Promise.resolve({ default: { - lazy: {} as Procedure<{ anything: boolean }, object, typeof schema2, typeof schema2, object>, - } }), + loader: () => + Promise.resolve({ + default: { + lazy: {} as Procedure< + { anything: boolean }, + object, + typeof schema2, + typeof schema2, + object + >, + }, + }), meta: {}, }), } @@ -331,40 +450,78 @@ describe('RouterImplementer', () => { expectTypeOf(implemented).toEqualTypeOf>() - // @ts-expect-error - invalid loader - implementer.lazy(() => Promise.resolve({ default: { - ping: {} as Procedure<{ anything: boolean }, object, typeof schema1, typeof schema2, { BASE: { data: typeof schema1 } }>, - - nested: { - pong: {} as Procedure<{ anything: boolean, optional?: boolean }, { anything: boolean }, Schema<'invalid'>, typeof schema1, object>, - }, - - use: {} as Procedure<{ anything: boolean }, object, typeof schema3, typeof schema1, object>, - - middleware: {} as Procedure<{ anything: boolean }, object, typeof schema1, typeof schema1, object>, - - router: {}, - - lazy: new Lazy({ - loader: () => Promise.resolve({ default: {} }), - meta: {}, + implementer.lazy(() => + // @ts-expect-error - invalid loader + Promise.resolve({ + default: { + ping: {} as Procedure< + { anything: boolean }, + object, + typeof schema1, + typeof schema2, + { BASE: { data: typeof schema1 } } + >, + + nested: { + pong: {} as Procedure< + { anything: boolean; optional?: boolean }, + { anything: boolean }, + Schema<'invalid'>, + typeof schema1, + object + >, + }, + + use: {} as Procedure< + { anything: boolean }, + object, + typeof schema3, + typeof schema1, + object + >, + + middleware: {} as Procedure< + { anything: boolean }, + object, + typeof schema1, + typeof schema1, + object + >, + + router: {}, + + lazy: new Lazy({ + loader: () => Promise.resolve({ default: {} }), + meta: {}, + }), + }, }), - } })) + ) }) it('deep access', () => { const router = { - pong: {} as Procedure<{ anything: boolean, something?: boolean }, { anything: boolean }, typeof schema2, typeof schema1, object>, + pong: {} as Procedure< + { anything: boolean; something?: boolean }, + { anything: boolean }, + typeof schema2, + typeof schema1, + object + >, } const implemented = implementer.nested.lazy(() => Promise.resolve({ default: router })) expectTypeOf(implemented).toEqualTypeOf>() - // @ts-expect-error - expect a procedure - implementer.nested.lazy(() => Promise.resolve({ default: { - pong: {}, - } })) + implementer.nested.lazy(() => + // @ts-expect-error - expect a procedure + Promise.resolve({ + default: { + pong: {}, + }, + }), + ) }) it('conflict method', () => { @@ -376,28 +533,55 @@ describe('RouterImplementer', () => { expectTypeOf(implemented).toEqualTypeOf>() - // @ts-expect-error - input schema is conflict - implementer.lazy.lazy(() => Promise.resolve({ default: { - lazy: {} as Procedure<{ anything: boolean }, object, Schema<'invalid'>, typeof schema2, object>, - } })) + implementer.lazy.lazy(() => + // @ts-expect-error - input schema is conflict + Promise.resolve({ + default: { + lazy: {} as Procedure< + { anything: boolean }, + object, + Schema<'invalid'>, + typeof schema2, + object + >, + }, + }), + ) }) }) describe('edge case', () => { it('is procedure implementer if implementer for single procedure', () => { - expectTypeOf>() - .toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf< + ProcedureImplementer< + { auth: boolean }, + object, + typeof schema1, + typeof schema2, + typeof errorMap + > + >() }) }) }) describe('RouterImplementerWithMiddlewares', () => { - const implementer = {} as RouterImplementerWithMiddlewares + const implementer = {} as RouterImplementerWithMiddlewares< + TContract, + { auth: boolean }, + { extra: boolean } + > describe('deep access', () => { it('normal', () => { expectTypeOf(implementer.ping).toEqualTypeOf< - ProcedureImplementer<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema2, typeof errorMap> + ProcedureImplementer< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap + > >() expectTypeOf(implementer.nested).toEqualTypeOf< @@ -405,39 +589,97 @@ describe('RouterImplementerWithMiddlewares', () => { >() expectTypeOf(implementer.nested.pong).toEqualTypeOf< - ProcedureImplementer<{ auth: boolean }, { extra: boolean }, typeof schema2, typeof schema1, object> + ProcedureImplementer< + { auth: boolean }, + { extra: boolean }, + typeof schema2, + typeof schema1, + object + > >() }) it('conflict methods', () => { expectTypeOf(implementer.use).toEqualTypeOf< - & SharedRouterImplementerWithMiddlewares['use'] - & ProcedureImplementer<{ auth: boolean }, { extra: boolean }, typeof schema3, typeof schema3, object> + SharedRouterImplementerWithMiddlewares< + TContract, + { auth: boolean }, + { extra: boolean } + >['use'] & + ProcedureImplementer< + { auth: boolean }, + { extra: boolean }, + typeof schema3, + typeof schema3, + object + > >() expectTypeOf(implementer.middleware).toEqualTypeOf< - & SharedRouterImplementerWithMiddlewares['middleware'] - & ProcedureImplementer<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema1, object> + SharedRouterImplementerWithMiddlewares< + TContract, + { auth: boolean }, + { extra: boolean } + >['middleware'] & + ProcedureImplementer< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema1, + object + > >() expectTypeOf(implementer.router).toEqualTypeOf< - & SharedRouterImplementerWithMiddlewares['router'] - & RouterImplementerWithMiddlewares + SharedRouterImplementerWithMiddlewares< + TContract, + { auth: boolean }, + { extra: boolean } + >['router'] & + RouterImplementerWithMiddlewares< + TContract['router'], + { auth: boolean }, + { extra: boolean } + > >() expectTypeOf(implementer.router.router).toEqualTypeOf< - & SharedRouterImplementerWithMiddlewares['router'] - & ProcedureImplementer<{ auth: boolean }, { extra: boolean }, typeof schema2, typeof schema2, object> + SharedRouterImplementerWithMiddlewares< + TContract['router'], + { auth: boolean }, + { extra: boolean } + >['router'] & + ProcedureImplementer< + { auth: boolean }, + { extra: boolean }, + typeof schema2, + typeof schema2, + object + > >() expectTypeOf(implementer.lazy).toEqualTypeOf< - & SharedRouterImplementerWithMiddlewares['lazy'] - & RouterImplementerWithMiddlewares + SharedRouterImplementerWithMiddlewares< + TContract, + { auth: boolean }, + { extra: boolean } + >['lazy'] & + RouterImplementerWithMiddlewares >() expectTypeOf(implementer.lazy.lazy).toEqualTypeOf< - & SharedRouterImplementerWithMiddlewares['lazy'] - & ProcedureImplementer<{ auth: boolean }, { extra: boolean }, typeof schema2, typeof schema2, object> + SharedRouterImplementerWithMiddlewares< + TContract['lazy'], + { auth: boolean }, + { extra: boolean } + >['lazy'] & + ProcedureImplementer< + { auth: boolean }, + { extra: boolean }, + typeof schema2, + typeof schema2, + object + > >() }) }) @@ -447,7 +689,9 @@ describe('RouterImplementerWithMiddlewares', () => { const decorated = implementer.use(({ next, errors, context }, input, done) => { expectTypeOf(input).toEqualTypeOf() expectTypeOf(done).toEqualTypeOf>() - expectTypeOf(errors).toEqualTypeOf>>() + expectTypeOf(errors).toEqualTypeOf< + ORPCErrorConstructorMap> + >() expectTypeOf(context).toEqualTypeOf>() if ('BASE' in errors) { @@ -479,12 +723,22 @@ describe('RouterImplementerWithMiddlewares', () => { }) it('outline middleware', () => { - const middleware = {} as Middleware<{ auth: boolean, g?: boolean }, { more: string }, unknown, any, { SOME_ERROR: { message: string } }> + const middleware = {} as Middleware< + { auth: boolean; g?: boolean }, + { more: string }, + unknown, + any, + { SOME_ERROR: { message: string } } + > expectTypeOf(implementer.use(middleware)).toEqualTypeOf< RouterImplementerWithMiddlewares< TContract, - MergedInitialContext<{ auth: boolean }, { extra: boolean }, { auth: boolean, g?: boolean }>, + MergedInitialContext< + { auth: boolean }, + { extra: boolean }, + { auth: boolean; g?: boolean } + >, MergedContext<{ extra: boolean }, { more: string }> > >() @@ -532,7 +786,9 @@ describe('RouterImplementerWithMiddlewares', () => { expectTypeOf(input).toEqualTypeOf() expectTypeOf(done).toEqualTypeOf>() expectTypeOf(context).toEqualTypeOf>() - expectTypeOf(errors).toEqualTypeOf>>() + expectTypeOf(errors).toEqualTypeOf< + ORPCErrorConstructorMap> + >() if ('BASE' in errors) { expectTypeOf(errors.BASE).toBeFunction() @@ -547,8 +803,16 @@ describe('RouterImplementerWithMiddlewares', () => { }) it('with TOutContext', () => { - expectTypeOf(implementer.middleware(({ next }) => next({ context: { more: 'yes' } }))).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean }, MergedContext<{ extra: boolean }, { more: string }>, unknown, any, object> + expectTypeOf( + implementer.middleware(({ next }) => next({ context: { more: 'yes' } })), + ).toEqualTypeOf< + DecoratedMiddleware< + { auth: boolean }, + MergedContext<{ extra: boolean }, { more: string }>, + unknown, + any, + object + > >() // @ts-expect-error - conflict with TInitialContext @@ -559,14 +823,26 @@ describe('RouterImplementerWithMiddlewares', () => { }) it('with expected input', () => { - expectTypeOf(implementer.middleware(({ next }, input: { schema1: number }) => next({}))).toEqualTypeOf< + expectTypeOf( + implementer.middleware(({ next }, input: { schema1: number }) => next({})), + ).toEqualTypeOf< DecoratedMiddleware<{ auth: boolean }, { extra: boolean }, { schema1: number }, any, object> >() }) it('with expected output', () => { - expectTypeOf(implementer.middleware(({ next }, input, done: MiddlewareDone<{ schema2: number }>) => next({}))).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean }, { extra: boolean }, unknown, { schema2: number }, object> + expectTypeOf( + implementer.middleware(({ next }, input, done: MiddlewareDone<{ schema2: number }>) => + next({}), + ), + ).toEqualTypeOf< + DecoratedMiddleware< + { auth: boolean }, + { extra: boolean }, + unknown, + { schema2: number }, + object + > >() }) @@ -574,7 +850,13 @@ describe('RouterImplementerWithMiddlewares', () => { const mid = implementer.nested.middleware(({ next }) => next({ context: { more: true } })) expectTypeOf(mid).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean }, MergedContext<{ extra: boolean }, { more: boolean }>, unknown, any, object> + DecoratedMiddleware< + { auth: boolean }, + MergedContext<{ extra: boolean }, { more: boolean }>, + unknown, + any, + object + > >() }) @@ -582,7 +864,13 @@ describe('RouterImplementerWithMiddlewares', () => { const mid = implementer.middleware(({ next }) => next({ context: { more: true } })) expectTypeOf(mid).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean }, MergedContext<{ extra: boolean }, { more: boolean }>, unknown, any, object> + DecoratedMiddleware< + { auth: boolean }, + MergedContext<{ extra: boolean }, { more: boolean }>, + unknown, + any, + object + > >() }) }) @@ -590,20 +878,53 @@ describe('RouterImplementerWithMiddlewares', () => { describe('.router', () => { it('works', () => { const router = { - ping: {} as Procedure<{ auth: boolean, extra: boolean }, object, typeof schema1, typeof schema2, typeof errorMap>, + ping: {} as Procedure< + { auth: boolean; extra: boolean }, + object, + typeof schema1, + typeof schema2, + typeof errorMap + >, nested: { - pong: {} as Procedure<{ auth: boolean, optional?: boolean }, { anything: boolean }, typeof schema2, typeof schema1, object>, + pong: {} as Procedure< + { auth: boolean; optional?: boolean }, + { anything: boolean }, + typeof schema2, + typeof schema1, + object + >, }, use: {} as Procedure<{ auth: boolean }, object, typeof schema3, typeof schema3, object>, - middleware: {} as Procedure<{ auth: boolean }, object, typeof schema1, typeof schema1, object>, + middleware: {} as Procedure< + { auth: boolean }, + object, + typeof schema1, + typeof schema1, + object + >, router: { - router: {} as Procedure<{ auth: boolean }, object, typeof schema2, typeof schema2, object>, + router: {} as Procedure< + { auth: boolean }, + object, + typeof schema2, + typeof schema2, + object + >, }, lazy: new Lazy({ - loader: () => Promise.resolve({ default: { - lazy: {} as Procedure<{ auth: boolean }, object, typeof schema2, typeof schema2, object>, - } }), + loader: () => + Promise.resolve({ + default: { + lazy: {} as Procedure< + { auth: boolean }, + object, + typeof schema2, + typeof schema2, + object + >, + }, + }), meta: {}, }), } @@ -611,21 +932,28 @@ describe('RouterImplementerWithMiddlewares', () => { const implemented = implementer.router(router) expectTypeOf(implemented).toEqualTypeOf< - AugmentedRouterWithMiddlewares< - typeof router, - { auth: boolean }, - { extra: boolean }, - object - > + AugmentedRouterWithMiddlewares >() implementer.router({ // @ts-expect-error - error map is conflict - ping: {} as Procedure<{ auth: boolean }, object, typeof schema1, typeof schema2, { BASE: { data: typeof schema1 } }>, + ping: {} as Procedure< + { auth: boolean }, + object, + typeof schema1, + typeof schema2, + { BASE: { data: typeof schema1 } } + >, nested: { // @ts-expect-error - input schema is conflict - pong: {} as Procedure<{ auth: boolean, optional?: boolean }, { anything: boolean }, Schema<'invalid'>, typeof schema1, object>, + pong: {} as Procedure< + { auth: boolean; optional?: boolean }, + { anything: boolean }, + Schema<'invalid'>, + typeof schema1, + object + >, }, // @ts-expect-error - output schema is conflict @@ -635,9 +963,18 @@ describe('RouterImplementerWithMiddlewares', () => { router: {}, // @ts-expect-error - context is conflict lazy: new Lazy({ - loader: () => Promise.resolve({ default: { - lazy: {} as Procedure<{ auth: 'invalid' }, object, typeof schema2, typeof schema2, object>, - } }), + loader: () => + Promise.resolve({ + default: { + lazy: {} as Procedure< + { auth: 'invalid' }, + object, + typeof schema2, + typeof schema2, + object + >, + }, + }), meta: {}, }), }) @@ -645,18 +982,19 @@ describe('RouterImplementerWithMiddlewares', () => { it('deep access', () => { const router = { - pong: {} as Procedure<{ auth: boolean, something?: boolean }, { anything: boolean }, typeof schema2, typeof schema1, object>, + pong: {} as Procedure< + { auth: boolean; something?: boolean }, + { anything: boolean }, + typeof schema2, + typeof schema1, + object + >, } const implemented = implementer.nested.router(router) expectTypeOf(implemented).toEqualTypeOf< - AugmentedRouterWithMiddlewares< - typeof router, - { auth: boolean }, - { extra: boolean }, - object - > + AugmentedRouterWithMiddlewares >() implementer.nested.router({ @@ -673,17 +1011,18 @@ describe('RouterImplementerWithMiddlewares', () => { const implemented = implementer.router.router(router) expectTypeOf(implemented).toEqualTypeOf< - AugmentedRouterWithMiddlewares< - typeof router, - { auth: boolean }, - { extra: boolean }, - object - > + AugmentedRouterWithMiddlewares >() implementer.router.router({ // @ts-expect-error - input schema is conflict - router: {} as Procedure<{ auth: boolean }, object, Schema<'invalid'>, typeof schema2, object>, + router: {} as Procedure< + { auth: boolean }, + object, + Schema<'invalid'>, + typeof schema2, + object + >, }) }) }) @@ -691,20 +1030,53 @@ describe('RouterImplementerWithMiddlewares', () => { describe('.lazy', () => { it('works', () => { const router = { - ping: {} as Procedure<{ auth: boolean, extra: boolean }, object, typeof schema1, typeof schema2, typeof errorMap>, + ping: {} as Procedure< + { auth: boolean; extra: boolean }, + object, + typeof schema1, + typeof schema2, + typeof errorMap + >, nested: { - pong: {} as Procedure<{ auth: boolean, optional?: boolean }, { anything: boolean }, typeof schema2, typeof schema1, object>, + pong: {} as Procedure< + { auth: boolean; optional?: boolean }, + { anything: boolean }, + typeof schema2, + typeof schema1, + object + >, }, use: {} as Procedure<{ auth: boolean }, object, typeof schema3, typeof schema3, object>, - middleware: {} as Procedure<{ auth: boolean }, object, typeof schema1, typeof schema1, object>, + middleware: {} as Procedure< + { auth: boolean }, + object, + typeof schema1, + typeof schema1, + object + >, router: { - router: {} as Procedure<{ auth: boolean }, object, typeof schema2, typeof schema2, object>, + router: {} as Procedure< + { auth: boolean }, + object, + typeof schema2, + typeof schema2, + object + >, }, lazy: new Lazy({ - loader: () => Promise.resolve({ default: { - lazy: {} as Procedure<{ auth: boolean }, object, typeof schema2, typeof schema2, object>, - } }), + loader: () => + Promise.resolve({ + default: { + lazy: {} as Procedure< + { auth: boolean }, + object, + typeof schema2, + typeof schema2, + object + >, + }, + }), meta: {}, }), } @@ -722,32 +1094,69 @@ describe('RouterImplementerWithMiddlewares', () => { > >() - // @ts-expect-error - invalid loader - implementer.lazy(() => Promise.resolve({ default: { - ping: {} as Procedure<{ auth: boolean, extra: boolean }, object, typeof schema1, typeof schema2, { BASE: { data: typeof schema1 } }>, - - nested: { - pong: {} as Procedure<{ auth: boolean, optional?: boolean }, { anything: boolean }, Schema<'invalid'>, typeof schema1, object>, - }, - - use: {} as Procedure<{ auth: boolean }, object, typeof schema3, typeof schema1, object>, - - middleware: {} as Procedure<{ auth: boolean }, object, typeof schema1, typeof schema1, object>, - - router: {}, - - lazy: new Lazy({ - loader: () => Promise.resolve({ default: { - lazy: {} as Procedure<{ auth: 'invalid' }, object, typeof schema2, typeof schema2, object>, - } }), - meta: {}, + implementer.lazy(() => + // @ts-expect-error - invalid loader + Promise.resolve({ + default: { + ping: {} as Procedure< + { auth: boolean; extra: boolean }, + object, + typeof schema1, + typeof schema2, + { BASE: { data: typeof schema1 } } + >, + + nested: { + pong: {} as Procedure< + { auth: boolean; optional?: boolean }, + { anything: boolean }, + Schema<'invalid'>, + typeof schema1, + object + >, + }, + + use: {} as Procedure<{ auth: boolean }, object, typeof schema3, typeof schema1, object>, + + middleware: {} as Procedure< + { auth: boolean }, + object, + typeof schema1, + typeof schema1, + object + >, + + router: {}, + + lazy: new Lazy({ + loader: () => + Promise.resolve({ + default: { + lazy: {} as Procedure< + { auth: 'invalid' }, + object, + typeof schema2, + typeof schema2, + object + >, + }, + }), + meta: {}, + }), + }, }), - } })) + ) }) it('deep access', () => { const router = { - pong: {} as Procedure<{ auth: boolean, something?: boolean }, { anything: boolean }, typeof schema2, typeof schema1, object>, + pong: {} as Procedure< + { auth: boolean; something?: boolean }, + { anything: boolean }, + typeof schema2, + typeof schema1, + object + >, } const implemented = implementer.nested.lazy(() => Promise.resolve({ default: router })) @@ -763,10 +1172,14 @@ describe('RouterImplementerWithMiddlewares', () => { > >() - // @ts-expect-error - expect a procedure - implementer.nested.lazy(() => Promise.resolve({ default: { - pong: {}, - } })) + implementer.nested.lazy(() => + // @ts-expect-error - expect a procedure + Promise.resolve({ + default: { + pong: {}, + }, + }), + ) }) it('conflict procedure/router names with implementer methods', () => { @@ -778,17 +1191,36 @@ describe('RouterImplementerWithMiddlewares', () => { expectTypeOf(implemented).toEqualTypeOf>() - // @ts-expect-error - input schema is conflict - implementer.lazy.lazy(() => Promise.resolve({ default: { - lazy: {} as Procedure<{ anything: boolean }, object, Schema<'invalid'>, typeof schema2, object>, - } })) + implementer.lazy.lazy(() => + // @ts-expect-error - input schema is conflict + Promise.resolve({ + default: { + lazy: {} as Procedure< + { anything: boolean }, + object, + Schema<'invalid'>, + typeof schema2, + object + >, + }, + }), + ) }) }) describe('edge case', () => { it('is procedure implementer if implementer for single procedure', () => { - expectTypeOf>() - .toEqualTypeOf>() + expectTypeOf< + RouterImplementerWithMiddlewares + >().toEqualTypeOf< + ProcedureImplementer< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap + > + >() }) }) }) diff --git a/packages/server/src/implementer-router.test.ts b/packages/server/src/implementer-router.test.ts index 12c18771af..5d637d3884 100644 --- a/packages/server/src/implementer-router.test.ts +++ b/packages/server/src/implementer-router.test.ts @@ -1,15 +1,16 @@ import { oc, ProcedureContract } from '@orpc/contract' import { getOwn } from '@orpc/shared' -import z from 'zod' -import { os } from './builder' -import { ImplementedProcedure, ProcedureImplementer } from './implementer-procedure' -import { createRouterImplementer } from './implementer-router' -import { Lazy } from './lazy' -import * as MiddlewareDecoratedModule from './middleware-decorated' -import { Procedure } from './procedure' -import * as RouterHiddenModule from './router-hidden' -import { getHiddenRouterContract } from './router-hidden' -import * as RouterUtilsModule from './router-utils' +import * as z from 'zod' + +import { os } from './builder.ts' +import { ImplementedProcedure, ProcedureImplementer } from './implementer-procedure.ts' +import { createRouterImplementer } from './implementer-router.ts' +import { Lazy } from './lazy.ts' +import * as MiddlewareDecoratedModule from './middleware-decorated.ts' +import { Procedure } from './procedure.ts' +import * as RouterHiddenModule from './router-hidden.ts' +import { getHiddenRouterContract } from './router-hidden.ts' +import * as RouterUtilsModule from './router-utils.ts' const decorateMiddlewareSpy = vi.spyOn(MiddlewareDecoratedModule, 'decorateMiddleware') const augmentImplementedRouterSpy = vi.spyOn(RouterUtilsModule, 'augmentImplementedRouter') @@ -88,7 +89,9 @@ describe('createRouterImplementer', () => { const mid = vi.fn() const applied = implementer.router.use(mid) - expect(applied.router.handler(vi.fn())['~orpc'].orderedMiddlewares).toEqual([{ middleware: mid }]) + expect(applied.router.handler(vi.fn())['~orpc'].orderedMiddlewares).toEqual([ + { middleware: mid }, + ]) }) }) @@ -159,7 +162,10 @@ describe('createRouterImplementer', () => { }) expect(withHiddenRouterContractSpy).toHaveBeenCalledTimes(1) - expect(withHiddenRouterContractSpy).toHaveBeenCalledWith(augmentImplementedRouterSpy.mock.results[0]!.value, contract) + expect(withHiddenRouterContractSpy).toHaveBeenCalledWith( + augmentImplementedRouterSpy.mock.results[0]!.value, + contract, + ) expect(applied).toBe(withHiddenRouterContractSpy.mock.results[0]!.value) }) @@ -180,7 +186,9 @@ describe('createRouterImplementer', () => { middlewares: [mid], }) - expect((applied as any).nested).toBe(augmentImplementedRouterSpy.mock.results[0]!.value.nested) + expect((applied as any).nested).toBe( + augmentImplementedRouterSpy.mock.results[0]!.value.nested, + ) expect(getHiddenRouterContract(applied)).toBe(contract) }) @@ -188,7 +196,10 @@ describe('createRouterImplementer', () => { const middleware = vi.fn() const applied = implementer.router.use(middleware) - expect(applied.router.handler(vi.fn())['~orpc'].orderedMiddlewares).toEqual([{ middleware: mid }, { middleware }]) + expect(applied.router.handler(vi.fn())['~orpc'].orderedMiddlewares).toEqual([ + { middleware: mid }, + { middleware }, + ]) }) }) @@ -237,7 +248,10 @@ describe('createRouterImplementer', () => { }) it('keeps __proto__ keys as own procedure implementers', () => { - const contract = Object.fromEntries([['__proto__', oc.output(z.string())], ['other', oc.output(z.string())]]) + const contract = Object.fromEntries([ + ['__proto__', oc.output(z.string())], + ['other', oc.output(z.string())], + ]) const implementer = createRouterImplementer(contract, config) as any @@ -248,17 +262,26 @@ describe('createRouterImplementer', () => { it('strips server procedures down to plain contracts, keeping contract-first routers as is', () => { const contractFirst = { ping: oc.errors({ BAD_GATEWAY: {} }) } const real = { - ping: os.$config({ disableInputValidation: true }).input(z.string()).handler(() => 'REAL'), + ping: os + .$config({ disableInputValidation: true }) + .input(z.string()) + .handler(() => 'REAL'), nested: { pong: os.handler(() => 'REAL') }, contractFirst, } - const hidden = getHiddenRouterContract(createRouterImplementer(real, config).router({} as any)) as any + const hidden = getHiddenRouterContract( + createRouterImplementer(real, config).router({} as any), + ) as any - for (const [procedure, original] of [[hidden.ping, real.ping], [hidden.nested.pong, real.nested.pong]] as const) { + for (const [procedure, original] of [ + [hidden.ping, real.ping], + [hidden.nested.pong, real.nested.pong], + ] as const) { expect(procedure).toBeInstanceOf(ProcedureContract) expect(procedure).not.toBeInstanceOf(Procedure) - const { handler, orderedMiddlewares, disableInputValidation, ...contractDef } = original['~orpc'] + const { handler, orderedMiddlewares, disableInputValidation, ...contractDef } = + original['~orpc'] expect(procedure['~orpc']).toEqual(contractDef) } diff --git a/packages/server/src/implementer-router.ts b/packages/server/src/implementer-router.ts index 7fc5fbc6bb..ad91f9e5e4 100644 --- a/packages/server/src/implementer-router.ts +++ b/packages/server/src/implementer-router.ts @@ -1,19 +1,20 @@ import type { ErrorMap, InferRouterContractErrorMap, RouterContract } from '@orpc/contract' -import type { IntersectPick, Public } from '@orpc/shared' -import type { Context, MergedContext, MergedInitialContext } from './context' -import type { AnyMiddleware, Middleware } from './middleware' -import type { DecoratedMiddleware } from './middleware-decorated' -import type { AnyProcedure, ProcedureConfig } from './procedure' -import type { ContractedRouter } from './router' -import type { AugmentedRouterWithMiddlewares } from './router-utils' import { ProcedureContract } from '@orpc/contract' +import type { IntersectPick, Public } from '@orpc/shared' import { bindMethods, omit, setOwn } from '@orpc/shared' -import { ProcedureImplementer } from './implementer-procedure' -import { Lazy } from './lazy' -import { decorateMiddleware } from './middleware-decorated' -import { Procedure } from './procedure' -import { withHiddenRouterContract } from './router-hidden' -import { augmentImplementedRouter } from './router-utils' + +import type { Context, MergedContext, MergedInitialContext } from './context.ts' +import { ProcedureImplementer } from './implementer-procedure.ts' +import { Lazy } from './lazy.ts' +import type { DecoratedMiddleware } from './middleware-decorated.ts' +import { decorateMiddleware } from './middleware-decorated.ts' +import type { AnyMiddleware, Middleware } from './middleware.ts' +import type { AnyProcedure, ProcedureConfig } from './procedure.ts' +import { Procedure } from './procedure.ts' +import { withHiddenRouterContract } from './router-hidden.ts' +import type { AugmentedRouterWithMiddlewares } from './router-utils.ts' +import { augmentImplementedRouter } from './router-utils.ts' +import type { ContractedRouter } from './router.ts' export class SharedRouterImplementer< TContract extends RouterContract, @@ -27,25 +28,31 @@ export class SharedRouterImplementer< static create( contract: TContract, - config: ProcedureConfig + config: ProcedureConfig, ): SharedRouterImplementer - static create( + static create< + TContract extends RouterContract, + TInitialContext extends Context, + TInjectedContext extends Context, + >( contract: TContract, config: ProcedureConfig, middlewares: AnyMiddleware[], ): SharedRouterImplementerWithMiddlewares - static create( + static create< + TContract extends RouterContract, + TInitialContext extends Context, + TInjectedContext extends Context, + >( contract: TContract, config: ProcedureConfig, middlewares: AnyMiddleware[] = [], - ): SharedRouterImplementer | SharedRouterImplementerWithMiddlewares { - return new SharedRouterImplementer( - contract, - config, - middlewares, - ) + ): + | SharedRouterImplementer + | SharedRouterImplementerWithMiddlewares { + return new SharedRouterImplementer(contract, config, middlewares) } use< @@ -53,19 +60,16 @@ export class SharedRouterImplementer< $InContext extends Context = TInitialContext, $ErrorMap extends ErrorMap = InferRouterContractErrorMap, >( - middleware: Middleware< - $InContext | TInitialContext, - $OutContext, - unknown, - unknown, - $ErrorMap - >, + middleware: Middleware<$InContext | TInitialContext, $OutContext, unknown, unknown, $ErrorMap>, ): RouterImplementerWithMiddlewares< TContract, MergedInitialContext, $OutContext > { - return createRouterImplementerInternal(this.contract, this.config, [...this.middlewares, middleware]) as any + return createRouterImplementerInternal(this.contract, this.config, [ + ...this.middlewares, + middleware, + ]) as any } middleware< @@ -88,10 +92,7 @@ export class SharedRouterImplementer< $Output, object > { - const allMiddlewares = [ - ...this.middlewares, - middleware, - ] + const allMiddlewares = [...this.middlewares, middleware] let current = decorateMiddleware(allMiddlewares.shift()!) @@ -102,9 +103,7 @@ export class SharedRouterImplementer< return current } - router>( - router: T, - ): T { + router>(router: T): T { if (this.middlewares.length) { router = augmentImplementedRouter(router, { ...this.config, @@ -115,9 +114,7 @@ export class SharedRouterImplementer< return withHiddenRouterContract(router, this.contract) } - lazy>( - loader: () => Promise<{ default: T }>, - ): Lazy { + lazy>(loader: () => Promise<{ default: T }>): Lazy { return new Lazy({ loader: async () => { const { default: router } = await loader() @@ -134,7 +131,10 @@ export interface SharedRouterImplementerWithMiddlewares< TInjectedContext extends Context, > { use< - $OutContext extends IntersectPick, $OutContext>, + $OutContext extends IntersectPick< + MergedContext, + $OutContext + >, $InContext extends Context = MergedContext, $ErrorMap extends ErrorMap = InferRouterContractErrorMap, >( @@ -152,7 +152,10 @@ export interface SharedRouterImplementerWithMiddlewares< > middleware< - $OutContext extends IntersectPick, $OutContext>, + $OutContext extends IntersectPick< + MergedContext, + $OutContext + >, $Input, $InContext extends Context = MergedContext, $Output = any, // $Output = any by default is important to make middleware can be used in any output by default @@ -181,36 +184,38 @@ export interface SharedRouterImplementerWithMiddlewares< ): Lazy> } -export type RouterImplementer< - TContract extends RouterContract, - TInitialContext extends Context, -> = TContract extends ProcedureContract - ? ProcedureImplementer - : Public> & { - [K in keyof TContract]: TContract[K] extends RouterContract - ? RouterImplementer - : never - } +export type RouterImplementer = + TContract extends ProcedureContract + ? ProcedureImplementer + : Public> & { + [K in keyof TContract]: TContract[K] extends RouterContract + ? RouterImplementer + : never + } export type RouterImplementerWithMiddlewares< TContract extends RouterContract, TInitialContext extends Context, TInjectedContext extends Context, -> = TContract extends ProcedureContract - ? ProcedureImplementer - : SharedRouterImplementerWithMiddlewares & { - [K in keyof TContract]: TContract[K] extends RouterContract - ? RouterImplementerWithMiddlewares - : never - } +> = + TContract extends ProcedureContract + ? ProcedureImplementer< + TInitialContext, + TInjectedContext, + $InputSchema, + $OutputSchema, + $ErrorMap + > + : SharedRouterImplementerWithMiddlewares & { + [K in keyof TContract]: TContract[K] extends RouterContract + ? RouterImplementerWithMiddlewares + : never + } export function createRouterImplementer< TContract extends RouterContract, TInitialContext extends Context, ->( - contract: TContract, - config: ProcedureConfig, -): RouterImplementer { +>(contract: TContract, config: ProcedureConfig): RouterImplementer { return createRouterImplementerInternal(toPlainRouterContract(contract), config, []) as any } @@ -231,12 +236,14 @@ function toPlainRouterContract(contract: T): T { } // handler & orderedMiddlewares are what make a definition a procedure (see Procedure[Symbol.hasInstance]) - return new ProcedureContract(omit(contract['~orpc'] as AnyProcedure['~orpc'], [ - 'handler', - 'orderedMiddlewares', - 'disableInputValidation', - 'disableOutputValidation', - ])) as T + return new ProcedureContract( + omit(contract['~orpc'] as AnyProcedure['~orpc'], [ + 'handler', + 'orderedMiddlewares', + 'disableInputValidation', + 'disableOutputValidation', + ]), + ) as T } let copied: Record | undefined @@ -261,19 +268,25 @@ function createRouterImplementerInternal< contract: TContract, config: ProcedureConfig, middlewares: AnyMiddleware[], -): RouterImplementer | RouterImplementerWithMiddlewares { +): + | RouterImplementer + | RouterImplementerWithMiddlewares { if (contract instanceof ProcedureContract) { return new ProcedureImplementer({ ...config, ...contract['~orpc'], - orderedMiddlewares: middlewares.map(middleware => ({ middleware })), + orderedMiddlewares: middlewares.map((middleware) => ({ middleware })), }) as any } const implementer: Record = {} for (const [key, child] of Object.entries(contract)) { - setOwn(implementer, key, createRouterImplementerInternal(child as RouterContract, config, middlewares)) + setOwn( + implementer, + key, + createRouterImplementerInternal(child as RouterContract, config, middlewares), + ) } const shared = bindMethods(SharedRouterImplementer.create(contract, config, middlewares)) @@ -287,8 +300,7 @@ function createRouterImplementerInternal< return Reflect.get(child, p) }, }) - } - else { + } else { implementer[key] = method } } diff --git a/packages/server/src/implementer.test-d.ts b/packages/server/src/implementer.test-d.ts index 92cc44e987..ab97eeaef1 100644 --- a/packages/server/src/implementer.test-d.ts +++ b/packages/server/src/implementer.test-d.ts @@ -1,6 +1,7 @@ import type { ProcedureContract, RouterContract } from '@orpc/contract' -import type { Implementer } from './implementer' -import type { RouterImplementer } from './implementer-router' + +import type { RouterImplementer } from './implementer-router.ts' +import type { Implementer } from './implementer.ts' const contract = { ping: {} as ProcedureContract, @@ -38,6 +39,8 @@ describe('Implementer', () => { Implementer >() - expectTypeOf(implementer).toExtend>() + expectTypeOf(implementer).toExtend< + RouterImplementer + >() }) }) diff --git a/packages/server/src/implementer.test.ts b/packages/server/src/implementer.test.ts index 1cafd1d141..a0ecbb79a4 100644 --- a/packages/server/src/implementer.test.ts +++ b/packages/server/src/implementer.test.ts @@ -1,9 +1,10 @@ import type { RouterContract } from '@orpc/contract' import { oc } from '@orpc/contract' -import z from 'zod' -import { implement } from './implementer' -import { ImplementedProcedure, ProcedureImplementer } from './implementer-procedure' -import * as ImplementerRouter from './implementer-router' +import * as z from 'zod' + +import { ImplementedProcedure, ProcedureImplementer } from './implementer-procedure.ts' +import * as ImplementerRouter from './implementer-router.ts' +import { implement } from './implementer.ts' const createRouterImplementerSpy = vi.spyOn(ImplementerRouter, 'createRouterImplementer') @@ -28,7 +29,9 @@ describe('implement', () => { it('crate with router implementer', () => { void implement(contract, { disableInputValidation: false }) expect(createRouterImplementerSpy).toHaveBeenCalledTimes(1) - expect(createRouterImplementerSpy).toHaveBeenCalledWith(contract, { disableInputValidation: false }) + expect(createRouterImplementerSpy).toHaveBeenCalledWith(contract, { + disableInputValidation: false, + }) }) describe('.$context', () => { @@ -75,7 +78,9 @@ describe('implement', () => { expect(implementer.$context.use(mid)).toBeInstanceOf(ProcedureImplementer) expect(implementer.$context.use(mid)['~orpc']).toMatchObject(contract.$context['~orpc']) - expect(implementer.$context.use(mid)['~orpc'].orderedMiddlewares).toEqual([expect.objectContaining({ middleware: mid })]) + expect(implementer.$context.use(mid)['~orpc'].orderedMiddlewares).toEqual([ + expect.objectContaining({ middleware: mid }), + ]) expect(implementer.$context.handler(vi.fn())).toBeInstanceOf(ImplementedProcedure) expect(implementer.$context.handler(vi.fn())['~orpc']).toMatchObject(contract.$context['~orpc']) @@ -88,6 +93,8 @@ describe('implement', () => { const mid = vi.fn() expect(implementer.use(mid)).toBeInstanceOf(ProcedureImplementer) expect(implementer.use(mid)['~orpc']).toMatchObject(contract.ping['~orpc']) - expect(implementer.use(mid)['~orpc'].orderedMiddlewares).toEqual([expect.objectContaining({ middleware: mid })]) + expect(implementer.use(mid)['~orpc'].orderedMiddlewares).toEqual([ + expect.objectContaining({ middleware: mid }), + ]) }) }) diff --git a/packages/server/src/implementer.ts b/packages/server/src/implementer.ts index 1dfa75ef70..414f93f75a 100644 --- a/packages/server/src/implementer.ts +++ b/packages/server/src/implementer.ts @@ -1,21 +1,17 @@ import type { RouterContract } from '@orpc/contract' import type { AnyFunction } from '@orpc/shared' -import type { DefaultInitialContext } from './builder' -import type { Context } from './context' -import type { RouterImplementer } from './implementer-router' -import type { ProcedureConfig } from './procedure' import { isTypescriptObject } from '@orpc/shared' -import { createRouterImplementer } from './implementer-router' -export type Implementer< - TContract extends RouterContract, - TInitialContext extends Context, -> - = & { - $context(): Implementer - $config(config: ProcedureConfig): Implementer - } - & RouterImplementer +import type { DefaultInitialContext } from './builder.ts' +import type { Context } from './context.ts' +import type { RouterImplementer } from './implementer-router.ts' +import { createRouterImplementer } from './implementer-router.ts' +import type { ProcedureConfig } from './procedure.ts' + +export type Implementer = { + $context(): Implementer + $config(config: ProcedureConfig): Implementer +} & RouterImplementer /** * Turns a contract into an implementer, used to implement the contract's @@ -23,7 +19,10 @@ export type Implementer< * * @see {@link https://orpc.dev/docs/contract/implementation#implementer | Contract Implementation - Implementer} */ -export function implement( +export function implement< + TContract extends RouterContract, + TInitialContext extends Context = DefaultInitialContext, +>( contract: TContract, config: ProcedureConfig = {}, ): Implementer { @@ -37,8 +36,7 @@ export function implement implementer - } - else if (p === '$config') { + } else if (p === '$config') { method = (incoming: ProcedureConfig) => implement(contract, { ...config, ...incoming }) } diff --git a/packages/server/src/index.test.ts b/packages/server/src/index.test.ts index d7f8fd9aea..cd238b4412 100644 --- a/packages/server/src/index.test.ts +++ b/packages/server/src/index.test.ts @@ -1,5 +1,5 @@ it('exports os, ORPCError, createRouterClient, RPCJsonSerializer, RPCSerializer', async () => { - await expect(import('.')).resolves.toMatchObject({ + await expect(import('./index.ts')).resolves.toMatchObject({ os: expect.any(Object), ORPCError: expect.any(Function), createRouterClient: expect.any(Function), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 46af416248..f25b2dedc4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,44 +1,44 @@ -export * from './builder' -export * from './builder-variants' -export * from './constants' -export * from './context' -export * from './implementer' -export * from './implementer-procedure' -export { SharedRouterImplementer } from './implementer-router' +export * from './builder.ts' +export * from './builder-variants.ts' +export * from './constants.ts' +export * from './context.ts' +export * from './implementer.ts' +export * from './implementer-procedure.ts' +export { SharedRouterImplementer } from './implementer-router.ts' export type { RouterImplementer, RouterImplementerWithMiddlewares, SharedRouterImplementerWithMiddlewares, -} from './implementer-router' -export * from './lazy' -export * from './middleware' -export type { DecoratedMiddleware } from './middleware-decorated' -export * from './procedure' -export * from './procedure-client' -export * from './procedure-decorated' -export { call } from './procedure-utils' -export type { CallOptions, CallRest } from './procedure-utils' -export * from './router' +} from './implementer-router.ts' +export * from './lazy.ts' +export * from './middleware.ts' +export type { DecoratedMiddleware } from './middleware-decorated.ts' +export * from './procedure.ts' +export * from './procedure-client.ts' +export * from './procedure-decorated.ts' +export { call } from './procedure-utils.ts' +export type { CallOptions, CallRest } from './procedure-utils.ts' +export * from './router.ts' export type { /** * @deprecated Use `InferRouterFinalContexts` instead. */ InferRouterFinalContexts as InferRouterCurrentContexts, -} from './router' -export * from './router-client' +} from './router.ts' +export * from './router-client.ts' export { getRouter, resolveContractProcedure, unlazyRouter, walkProcedureContractsAsync, walkProcedureContractsSync, -} from './router-utils' +} from './router-utils.ts' export type { AugmentedRouter, AugmentedRouterWithMiddlewares, UnlaziedRouter, WalkProcedureContractsLazyResult, -} from './router-utils' +} from './router-utils.ts' export type { AnyORPCError, diff --git a/packages/server/src/lazy.test.ts b/packages/server/src/lazy.test.ts index a7db12a81b..19138b6dea 100644 --- a/packages/server/src/lazy.test.ts +++ b/packages/server/src/lazy.test.ts @@ -1,4 +1,4 @@ -import { Lazy, unlazy } from './lazy' +import { Lazy, unlazy } from './lazy.ts' describe('lazy', () => { const meta = { title: 'test' } diff --git a/packages/server/src/lazy.ts b/packages/server/src/lazy.ts index a0cab81794..16b5ca2606 100644 --- a/packages/server/src/lazy.ts +++ b/packages/server/src/lazy.ts @@ -10,9 +10,7 @@ export interface LazyDefinition { export class Lazy { '~orpc': LazyDefinition - constructor( - def: LazyDefinition, - ) { + 'constructor'(def: LazyDefinition) { this['~orpc'] = def } @@ -31,17 +29,22 @@ export class Lazy { } return ( - isTypescriptObject(instance) - && isTypescriptObject(instance['~orpc']) - && isTypescriptObject(instance['~orpc'].meta) - && (instance['~orpc'].metaPlugins === undefined || Array.isArray(instance['~orpc'].metaPlugins)) - && typeof instance['~orpc'].loader === 'function' + isTypescriptObject(instance) && + isTypescriptObject(instance['~orpc']) && + isTypescriptObject(instance['~orpc'].meta) && + (instance['~orpc'].metaPlugins === undefined || + Array.isArray(instance['~orpc'].metaPlugins)) && + typeof instance['~orpc'].loader === 'function' ) } } export type Lazyable = T | Lazy -export function unlazy>(maybeLazy: T): Promise<{ default: T extends Lazy ? U : T }> { - return maybeLazy instanceof Lazy ? maybeLazy['~orpc'].loader() : Promise.resolve({ default: maybeLazy as T extends Lazy ? U : T }) +export function unlazy>( + maybeLazy: T, +): Promise<{ default: T extends Lazy ? U : T }> { + return maybeLazy instanceof Lazy + ? maybeLazy['~orpc'].loader() + : Promise.resolve({ default: maybeLazy as T extends Lazy ? U : T }) } diff --git a/packages/server/src/middleware-decorated.test-d.ts b/packages/server/src/middleware-decorated.test-d.ts index 3205a2b56a..4d17209ed6 100644 --- a/packages/server/src/middleware-decorated.test-d.ts +++ b/packages/server/src/middleware-decorated.test-d.ts @@ -1,12 +1,19 @@ import type { ErrorMap, MergedErrorMap, ORPCErrorConstructorMap } from '@orpc/contract' -import type { MergedContext, MergedInitialContext } from './context' -import type { Middleware, MiddlewareDone } from './middleware' -import type { DecoratedMiddleware } from './middleware-decorated' + +import type { MergedContext, MergedInitialContext } from './context.ts' +import type { DecoratedMiddleware } from './middleware-decorated.ts' +import type { Middleware, MiddlewareDone } from './middleware.ts' const errorMap = { BASE: { message: 'bad request' } } satisfies ErrorMap describe('DecoratedMiddleware', () => { - const middleware = {} as DecoratedMiddleware<{ a: string }, { b: number }, { x: number }, { y: string }, typeof errorMap> + const middleware = {} as DecoratedMiddleware< + { a: string }, + { b: number }, + { x: number }, + { y: string }, + typeof errorMap + > it('.adaptInput', () => { const mapped = middleware.adaptInput((input: string) => ({ x: Number(input) })) @@ -23,7 +30,13 @@ describe('DecoratedMiddleware', () => { const withErrors = middleware.errors({ FORBIDDEN: { message: 'forbidden' } }) expectTypeOf(withErrors).toEqualTypeOf< - DecoratedMiddleware<{ a: string }, { b: number }, { x: number }, { y: string }, MergedErrorMap> + DecoratedMiddleware< + { a: string }, + { b: number }, + { x: number }, + { y: string }, + MergedErrorMap + > >() // @ts-expect-error errors are invalid @@ -73,11 +86,17 @@ describe('DecoratedMiddleware', () => { }) it('outline middleware', () => { - const middleware2 = {} as Middleware<{ a: string, b: number, g?: number }, { extra: boolean }, unknown, any, { SOME_ERROR: { message: string } }> + const middleware2 = {} as Middleware< + { a: string; b: number; g?: number }, + { extra: boolean }, + unknown, + any, + { SOME_ERROR: { message: string } } + > expectTypeOf(middleware.use(middleware2)).toEqualTypeOf< DecoratedMiddleware< - MergedInitialContext<{ a: string }, { b: number }, { a: string, b: number, g?: number }>, + MergedInitialContext<{ a: string }, { b: number }, { a: string; b: number; g?: number }>, MergedContext<{ b: number }, { extra: boolean }>, { x: number }, { y: string }, @@ -85,11 +104,15 @@ describe('DecoratedMiddleware', () => { > >() - // @ts-expect-error - input is invalid - void middleware.use({} as Middleware<{ a: string, b: number }, object, 'invalid', any, object>) + void middleware.use( + // @ts-expect-error - input is invalid + {} as Middleware<{ a: string; b: number }, object, 'invalid', any, object>, + ) - // @ts-expect-error - output is invalid - void middleware.use({} as Middleware<{ a: string, b: number }, object, unknown, 'invalid', object>) + void middleware.use( + // @ts-expect-error - output is invalid + {} as Middleware<{ a: string; b: number }, object, unknown, 'invalid', object>, + ) // @ts-expect-error - TInContext is not satisfy expected void middleware.use({} as Middleware<{ something: string }, object, unknown, any, object>) @@ -101,8 +124,14 @@ describe('DecoratedMiddleware', () => { void middleware.use({} as Middleware<{ a: string }, { b: 'invalid' }, unknown, any, object>) }) - it('low-priority mid\'s errors and ignore conflicts', () => { - const middleware2 = {} as Middleware + it("low-priority mid's errors and ignore conflicts", () => { + const middleware2 = {} as Middleware< + object, + object, + unknown, + any, + { BASE: { message: 'CONFLICT' }; EXTRA: { message: string } } + > expectTypeOf(middleware.use(middleware2)).toEqualTypeOf< DecoratedMiddleware< diff --git a/packages/server/src/middleware-decorated.test.ts b/packages/server/src/middleware-decorated.test.ts index 6ca1e5a4a5..9b1f85de5a 100644 --- a/packages/server/src/middleware-decorated.test.ts +++ b/packages/server/src/middleware-decorated.test.ts @@ -1,7 +1,8 @@ import * as ContractModule from '@orpc/contract' import { sleep } from '@orpc/shared' import { vi } from 'vitest' -import { decorateMiddleware } from './middleware-decorated' + +import { decorateMiddleware } from './middleware-decorated.ts' const mergeErrorMapSpy = vi.spyOn(ContractModule, 'mergeErrorMap') @@ -72,7 +73,10 @@ describe('decorateMiddleware', () => { it('should preserve metadata assigned after decoration', () => { const decorated = decorateMiddleware(vi.fn() as any) - decorated['~orpc'] = { errorMap: { BASE: { message: 'test' } }, metaPlugins: [{ name: 'test', init: vi.fn() }] } + decorated['~orpc'] = { + errorMap: { BASE: { message: 'test' } }, + metaPlugins: [{ name: 'test', init: vi.fn() }], + } expect(decorated.adaptInput((input: any) => input)['~orpc']).toBe(decorated['~orpc']) }) @@ -93,7 +97,9 @@ describe('decorateMiddleware', () => { const withErrors = decorated.errors({ NEW: { message: 'new' } }) expect(mergeErrorMapSpy).toHaveBeenCalledTimes(1) - expect(mergeErrorMapSpy).toHaveBeenCalledWith(original['~orpc']?.errorMap, { NEW: { message: 'new' } }) + expect(mergeErrorMapSpy).toHaveBeenCalledWith(original['~orpc']?.errorMap, { + NEW: { message: 'new' }, + }) expect(withErrors['~orpc']?.errorMap).toBe(mergeErrorMapSpy.mock.results[0]!.value) expect(withErrors['~orpc']?.metaPlugins).toBe(plugins) @@ -129,22 +135,43 @@ describe('decorateMiddleware', () => { const next = vi.fn().mockImplementation(async ({ context }) => ({ output: 'final', context })) const done = vi.fn() - const result = await (combined as any)({ context: { i1: true, i2: true, i3: true }, next }, 'input', done) - - expect(result).toEqual({ output: 'final', context: { i2: 'override', i3: 'override', mid1: true, mid11: 'override', mid2: true } }) + const result = await (combined as any)( + { context: { i1: true, i2: true, i3: true }, next }, + 'input', + done, + ) + + expect(result).toEqual({ + output: 'final', + context: { i2: 'override', i3: 'override', mid1: true, mid11: 'override', mid2: true }, + }) expect(mid1).toHaveBeenCalledTimes(1) - expect(mid1).toHaveBeenCalledWith(expect.objectContaining({ context: { i1: true, i2: true, i3: true } }), 'input', done) + expect(mid1).toHaveBeenCalledWith( + expect.objectContaining({ context: { i1: true, i2: true, i3: true } }), + 'input', + done, + ) expect(mid1).toHaveResolvedWith(result) expect(mid2).toHaveBeenCalledTimes(1) expect(mid2).toHaveBeenCalledAfter(mid1) - expect(mid2).toHaveBeenCalledWith(expect.objectContaining({ context: { i1: true, i2: 'override', i3: true, mid1: true, mid11: true } }), 'input', done) + expect(mid2).toHaveBeenCalledWith( + expect.objectContaining({ + context: { i1: true, i2: 'override', i3: true, mid1: true, mid11: true }, + }), + 'input', + done, + ) expect(mid2).toHaveResolvedWith(result) expect(next).toHaveBeenCalledTimes(1) expect(next).toHaveBeenCalledAfter(mid2) - expect(next).toHaveBeenCalledWith(expect.objectContaining({ context: { i2: 'override', i3: 'override', mid1: true, mid11: 'override', mid2: true } })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + context: { i2: 'override', i3: 'override', mid1: true, mid11: 'override', mid2: true }, + }), + ) expect(next).toHaveResolvedWith(result) expect(done).not.toHaveBeenCalled() @@ -158,19 +185,28 @@ describe('decorateMiddleware', () => { [undefined, undefined, outerSignal, outerSignal], [signal1, undefined, signal1, signal1], [signal1, signal2, signal1, signal2], - ])('forwards signal overrides through the combined chain (%#)', async (mid1Signal, mid2Signal, expectedMid2Signal, expectedNextSignal) => { - const mid1 = vi.fn().mockImplementation(async ({ next }) => next({ signal: mid1Signal })) - const mid2 = vi.fn().mockImplementation(async ({ next }) => next({ signal: mid2Signal })) - - const combined = decorateMiddleware(mid1 as any).use(mid2 as any) - const next = vi.fn().mockImplementation(async ({ context }) => ({ output: 'final', context })) - const done = vi.fn() - - await (combined as any)({ context: {}, signal: outerSignal, next }, 'input', done) - - expect(mid2).toHaveBeenCalledWith(expect.objectContaining({ signal: expectedMid2Signal }), 'input', done) - expect(next).toHaveBeenCalledWith(expect.objectContaining({ signal: expectedNextSignal })) - }) + ])( + 'forwards signal overrides through the combined chain (%#)', + async (mid1Signal, mid2Signal, expectedMid2Signal, expectedNextSignal) => { + const mid1 = vi.fn().mockImplementation(async ({ next }) => next({ signal: mid1Signal })) + const mid2 = vi.fn().mockImplementation(async ({ next }) => next({ signal: mid2Signal })) + + const combined = decorateMiddleware(mid1 as any).use(mid2 as any) + const next = vi + .fn() + .mockImplementation(async ({ context }) => ({ output: 'final', context })) + const done = vi.fn() + + await (combined as any)({ context: {}, signal: outerSignal, next }, 'input', done) + + expect(mid2).toHaveBeenCalledWith( + expect.objectContaining({ signal: expectedMid2Signal }), + 'input', + done, + ) + expect(next).toHaveBeenCalledWith(expect.objectContaining({ signal: expectedNextSignal })) + }, + ) it('can stop early with done', async () => { const mid1 = vi.fn().mockImplementation(async ({ next }) => { @@ -182,7 +218,7 @@ describe('decorateMiddleware', () => { const combined = decorateMiddleware(mid1 as any).use(mid2 as any) const next = vi.fn().mockImplementation(async ({ context }) => ({ output: 'final', context })) - const done = vi.fn().mockImplementation(opts => ({ ...opts, context: {} })) + const done = vi.fn().mockImplementation((opts) => ({ ...opts, context: {} })) const result = await (combined as any)({ context: { initial: true }, next }, 'input', done) @@ -192,12 +228,20 @@ describe('decorateMiddleware', () => { expect(done).toHaveBeenCalledWith({ output: 'early' }) expect(mid1).toHaveBeenCalledTimes(1) - expect(mid1).toHaveBeenCalledWith(expect.objectContaining({ context: { initial: true } }), 'input', done) + expect(mid1).toHaveBeenCalledWith( + expect.objectContaining({ context: { initial: true } }), + 'input', + done, + ) expect(mid1).toHaveResolvedWith(result) expect(mid2).toHaveBeenCalledTimes(1) expect(mid2).toHaveBeenCalledAfter(mid1) - expect(mid2).toHaveBeenCalledWith(expect.objectContaining({ context: { initial: true, mid1: true } }), 'input', done) + expect(mid2).toHaveBeenCalledWith( + expect.objectContaining({ context: { initial: true, mid1: true } }), + 'input', + done, + ) expect(mid2).toHaveResolvedWith(done.mock.results[0]!.value) expect(next).not.toHaveBeenCalled() @@ -205,18 +249,18 @@ describe('decorateMiddleware', () => { it('correctly handle multiple next calls', async () => { let mid1Result1 - const mid1 = vi.fn() - .mockImplementationOnce(async ({ next }) => { - const [result1, result2] = await Promise.all([ - next({ context: { mid1: 1 } }), - next({ context: { mid1: 2 } }), - ]) - - mid1Result1 = result1 - return result2 - }) + const mid1 = vi.fn().mockImplementationOnce(async ({ next }) => { + const [result1, result2] = await Promise.all([ + next({ context: { mid1: 1 } }), + next({ context: { mid1: 2 } }), + ]) + + mid1Result1 = result1 + return result2 + }) - const mid2 = vi.fn() + const mid2 = vi + .fn() .mockImplementationOnce(async ({ next }, input, done) => { await sleep(10) // this ensure they can handle in parallel return done({ output: 'done' }) @@ -225,7 +269,7 @@ describe('decorateMiddleware', () => { const combined = decorateMiddleware(mid1 as any).use(mid2 as any) const next = vi.fn().mockImplementation(({ context }) => ({ output: 'ok', context })) - const done = vi.fn().mockImplementation(opts => ({ ...opts, context: {} })) + const done = vi.fn().mockImplementation((opts) => ({ ...opts, context: {} })) const result = await (combined as any)({ context: { initial: true }, next }, 'input', done) @@ -236,13 +280,27 @@ describe('decorateMiddleware', () => { expect(done).toHaveBeenCalledWith({ output: 'done' }) expect(mid1).toHaveBeenCalledTimes(1) - expect(mid1).toHaveBeenCalledWith(expect.objectContaining({ context: { initial: true } }), 'input', done) + expect(mid1).toHaveBeenCalledWith( + expect.objectContaining({ context: { initial: true } }), + 'input', + done, + ) expect(mid1).toHaveResolvedWith(result) expect(mid2).toHaveBeenCalledTimes(2) expect(mid2).toHaveBeenCalledAfter(mid1) - expect(mid2).toHaveBeenNthCalledWith(1, expect.objectContaining({ context: { initial: true, mid1: 1 } }), 'input', done) - expect(mid2).toHaveBeenNthCalledWith(2, expect.objectContaining({ context: { initial: true, mid1: 2 } }), 'input', done) + expect(mid2).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ context: { initial: true, mid1: 1 } }), + 'input', + done, + ) + expect(mid2).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ context: { initial: true, mid1: 2 } }), + 'input', + done, + ) expect(mid2).toHaveNthResolvedWith(1, done.mock.results[0]!.value) expect(mid2).toHaveNthResolvedWith(2, result) @@ -274,7 +332,10 @@ describe('decorateMiddleware', () => { const combined = decorated1.use(mid2 as any) expect(mergeErrorMapSpy).toHaveBeenCalledTimes(1) - expect(mergeErrorMapSpy).toHaveBeenCalledWith(mid2['~orpc']?.errorMap, mid1['~orpc']?.errorMap) + expect(mergeErrorMapSpy).toHaveBeenCalledWith( + mid2['~orpc']?.errorMap, + mid1['~orpc']?.errorMap, + ) expect(combined['~orpc']?.errorMap).toEqual(mergeErrorMapSpy.mock.results[0]!.value) expect(combined.name).toBe('M1 + M2') @@ -287,7 +348,9 @@ describe('decorateMiddleware', () => { const decorated = decorateMiddleware(vi.fn() as any) decorated['~orpc'] = { metaPlugins: plugins1 } - const combined = decorated.use(Object.assign(vi.fn(), { '~orpc': { metaPlugins: plugins2 } }) as any) + const combined = decorated.use( + Object.assign(vi.fn(), { '~orpc': { metaPlugins: plugins2 } }) as any, + ) expect(combined['~orpc']?.metaPlugins).toEqual([...plugins1, ...plugins2]) }) }) diff --git a/packages/server/src/middleware-decorated.ts b/packages/server/src/middleware-decorated.ts index 64cf7ce76f..9ebe2d0f11 100644 --- a/packages/server/src/middleware-decorated.ts +++ b/packages/server/src/middleware-decorated.ts @@ -1,10 +1,16 @@ import type { ErrorMap, MergedErrorMap } from '@orpc/contract' -import type { IntersectPick } from '@orpc/shared' -import type { Context, MergedContext, MergedInitialContext } from './context' -import type { AnyMiddleware, Middleware, MiddlewareNextOptions, MiddlewareResult } from './middleware' import { mergeErrorMap } from '@orpc/contract' +import type { IntersectPick } from '@orpc/shared' import { toArray } from '@orpc/shared' +import type { Context, MergedContext, MergedInitialContext } from './context.ts' +import type { + AnyMiddleware, + Middleware, + MiddlewareNextOptions, + MiddlewareResult, +} from './middleware.ts' + export interface DecoratedMiddleware< TInContext extends Context, TOutContext extends Context, @@ -51,7 +57,13 @@ export function decorateMiddleware< >( middleware: Middleware, ): DecoratedMiddleware { - const decorated = ((...args) => middleware(...args)) as DecoratedMiddleware + const decorated = ((...args) => middleware(...args)) as DecoratedMiddleware< + TInContext, + TOutContext, + TInput, + TOutput, + TErrorMap + > decorated['~orpc'] = middleware['~orpc'] Object.defineProperty(decorated, 'name', { @@ -59,11 +71,9 @@ export function decorateMiddleware< }) decorated.adaptInput = (adapt) => { - const mapped = decorateMiddleware( - (opts: any, input: any, ...args: [any]) => { - return middleware(opts, adapt(input), ...args) - }, - ) + const mapped = decorateMiddleware((opts: any, input: any, ...args: [any]) => { + return middleware(opts, adapt(input), ...args) + }) mapped['~orpc'] = decorated['~orpc'] Object.defineProperty(mapped, 'name', { @@ -92,20 +102,26 @@ export function decorateMiddleware< async next(nextOpts1: undefined | MiddlewareNextOptions) { const signal = nextOpts1?.signal ?? opts.signal - const result: MiddlewareResult = await usedMiddleware({ - ...opts, - context: { ...opts.context, ...nextOpts1?.context }, - signal, - next(nextOpts2: undefined | MiddlewareNextOptions, ...args: []) { - return opts.next({ - context: { - ...nextOpts1?.context, - ...nextOpts2?.context, - }, - signal: nextOpts2?.signal ?? signal, - }, ...args) - }, - } as any, ...args) + const result: MiddlewareResult = await usedMiddleware( + { + ...opts, + context: { ...opts.context, ...nextOpts1?.context }, + signal, + next(nextOpts2: undefined | MiddlewareNextOptions, ...args: []) { + return opts.next( + { + context: { + ...nextOpts1?.context, + ...nextOpts2?.context, + }, + signal: nextOpts2?.signal ?? signal, + }, + ...args, + ) + }, + } as any, + ...args, + ) return { ...result, diff --git a/packages/server/src/middleware.test-d.ts b/packages/server/src/middleware.test-d.ts index ad2c2c5fcd..414dd4b3aa 100644 --- a/packages/server/src/middleware.test-d.ts +++ b/packages/server/src/middleware.test-d.ts @@ -1,6 +1,7 @@ import type { ErrorMap, ORPCErrorConstructorMap } from '@orpc/contract' -import type { Middleware } from './middleware' -import z from 'zod' +import * as z from 'zod' + +import type { Middleware } from './middleware.ts' const errorMap = { BASE: { @@ -10,13 +11,7 @@ const errorMap = { describe('Middleware', () => { it('can be a regular function', () => { - type T = Middleware< - { in: string }, - object, - { input: boolean }, - string, - typeof errorMap - > + type T = Middleware<{ in: string }, object, { input: boolean }, string, typeof errorMap> const mid: T = ({ next, context, errors }, input, done) => { expectTypeOf(context).toEqualTypeOf<{ in: string }>() @@ -32,13 +27,7 @@ describe('Middleware', () => { }) it('can stop by using done function', () => { - type T = Middleware< - { in: string }, - object, - { input: boolean }, - number, - typeof errorMap - > + type T = Middleware<{ in: string }, object, { input: boolean }, number, typeof errorMap> const mid: T = ({ next, context, errors }, input, done) => { return done({ output: 123 }) @@ -94,17 +83,15 @@ describe('Middleware', () => { }) it('can infer back error map', async () => { - const mid: Middleware< - { in: string }, - object, - { input: boolean }, - string, - typeof errorMap - > = ({ next }) => { + const mid: Middleware<{ in: string }, object, { input: boolean }, string, typeof errorMap> = ({ + next, + }) => { return next() } expectTypeOf(mid['~orpc']?.errorMap).toEqualTypeOf() - expectTypeOf ? TErrorMap : never>().toEqualTypeOf() + expectTypeOf< + typeof mid extends Middleware ? TErrorMap : never + >().toEqualTypeOf() }) }) diff --git a/packages/server/src/middleware.ts b/packages/server/src/middleware.ts index 3445ef7fa7..e293a64504 100644 --- a/packages/server/src/middleware.ts +++ b/packages/server/src/middleware.ts @@ -1,19 +1,20 @@ import type { AnyMetaPlugin, ErrorMap, ORPCErrorConstructorMap } from '@orpc/contract' import type { MaybeOptionalOptions, Promisable } from '@orpc/shared' -import type { Context } from './context' -import type { AnyProcedure } from './procedure' + +import type { Context } from './context.ts' +import type { AnyProcedure } from './procedure.ts' export type MiddlewareResult = Promisable<{ output: TOutput context: TOutContext }> -export type MiddlewareNextOptions - = & (object extends TOutContext ? { context?: TOutContext | undefined } : { context: TOutContext }) - & { - /** Overrides the signal for the rest of the chain. */ - signal?: AbortSignal | undefined - } +export type MiddlewareNextOptions = (object extends TOutContext + ? { context?: TOutContext | undefined } + : { context: TOutContext }) & { + /** Overrides the signal for the rest of the chain. */ + signal?: AbortSignal | undefined +} export interface MiddlewareNext { ( @@ -21,9 +22,9 @@ export interface MiddlewareNext { ): MiddlewareResult } -export type MiddlewareDoneOptions - = & (object extends TOutContext ? { context?: TOutContext | undefined } : { context: TOutContext }) - & { output: TOutput } +export type MiddlewareDoneOptions = (object extends TOutContext + ? { context?: TOutContext | undefined } + : { context: TOutContext }) & { output: TOutput } export interface MiddlewareDone { /** @@ -70,9 +71,7 @@ export interface Middleware< opts: MiddlewareOptions>, input: TInput, done: MiddlewareDone, - ): Promisable< - MiddlewareResult - > + ): Promisable> } export type AnyMiddleware = Middleware diff --git a/packages/server/src/plugins/batch.test.ts b/packages/server/src/plugins/batch.test.ts index 3c8b2d4cb8..ddb5eb16cc 100644 --- a/packages/server/src/plugins/batch.test.ts +++ b/packages/server/src/plugins/batch.test.ts @@ -1,15 +1,16 @@ -import type { StandardResponse } from '@standard-server/core' -import type { AnyRouter } from '../router' -import type { ResponseHeadersHandlerPluginContext } from './response-headers' import { ORPCError } from '@orpc/client' import { promiseWithResolvers, sleep } from '@orpc/shared' +import type { StandardResponse } from '@standard-server/core' import { decodePeerMessage } from '@standard-server/peer' -import { RPCHandler } from '../adapters/fetch/rpc-handler' -import { os } from '../builder' -import { BatchHandlerPlugin } from './batch' -import { RequestCompressionHandlerPlugin } from './request-compression' -import { RequestLimitHandlerPlugin } from './request-limit' -import { ResponseHeadersHandlerPlugin } from './response-headers' + +import { RPCHandler } from '../adapters/fetch/rpc-handler.ts' +import { os } from '../builder.ts' +import type { AnyRouter } from '../router.ts' +import { BatchHandlerPlugin } from './batch.ts' +import { RequestCompressionHandlerPlugin } from './request-compression.ts' +import { RequestLimitHandlerPlugin } from './request-limit.ts' +import type { ResponseHeadersHandlerPluginContext } from './response-headers.ts' +import { ResponseHeadersHandlerPlugin } from './response-headers.ts' beforeEach(() => { vi.clearAllMocks() @@ -149,19 +150,19 @@ describe('batchHandlerPlugin', () => { ping: os.handler(handlerFn), } - const createHandler = ( - plugin = new BatchHandlerPlugin(), - handlerRouter: AnyRouter = router, - ) => new RPCHandler(handlerRouter, { - plugins: [plugin, new ResponseHeadersHandlerPlugin()], - }) + const createHandler = (plugin = new BatchHandlerPlugin(), handlerRouter: AnyRouter = router) => + new RPCHandler(handlerRouter, { + plugins: [plugin, new ResponseHeadersHandlerPlugin()], + }) it('passes through non-batch requests', async () => { const handler = createHandler() - const { matched, response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - })) + const { matched, response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + }), + ) expect(matched).toBe(true) expect(response!.status).toBe(200) @@ -176,14 +177,16 @@ describe('batchHandlerPlugin', () => { makePeerRequestMessage('1', '/ping'), ] - const { matched, response } = await handler.handle(createBatchRequest({ - messages: peerMessages, - })) + const { matched, response } = await handler.handle( + createBatchRequest({ + messages: peerMessages, + }), + ) expect(matched).toBe(true) expect(response!.status).toBe(207) - const body = await response!.json() as any + const body = (await response!.json()) as any expect(Array.isArray(body)).toBe(true) expect(body).toHaveLength(2) expect(handlerFn).toHaveBeenCalledTimes(2) @@ -191,9 +194,12 @@ describe('batchHandlerPlugin', () => { it('returns binary payload when sub-response contains binary', async () => { const binaryRouter = { - file: os.handler(() => new Blob([new Uint8Array([1, 2, 3])], { - type: 'application/octet-stream', - })), + file: os.handler( + () => + new Blob([new Uint8Array([1, 2, 3])], { + type: 'application/octet-stream', + }), + ), ping: os.handler(() => 'pong'), } @@ -204,9 +210,11 @@ describe('batchHandlerPlugin', () => { makePeerRequestMessage('1', '/ping'), ] - const { response } = await handler.handle(createBatchRequest({ - messages: peerMessages, - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: peerMessages, + }), + ) expect(response!.status).toBe(207) expect(response!.headers.get('standard-server')).toEqual('file') @@ -222,14 +230,16 @@ describe('batchHandlerPlugin', () => { it('handles unmatched sub-requests as 404', async () => { const handler = createHandler() - const { matched, response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/nonexistent')], - })) + const { matched, response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/nonexistent')], + }), + ) expect(matched).toBe(true) expect(response!.status).toBe(207) - const body = await response!.json() as any + const body = (await response!.json()) as any expect(body).toHaveLength(1) expect(body[0]).toMatchObject({ kind: 'response', id: '0' }) expect(body[0].json.status).toBe(404) @@ -238,35 +248,44 @@ describe('batchHandlerPlugin', () => { it('returns 400 for invalid batch body', async () => { const handler = createHandler() - const { response } = await handler.handle(createBatchRequest({ - messages: 'not-an-array', - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: 'not-an-array', + }), + ) expect(response!.status).toBe(400) expect(await response!.text()).toContain('Invalid batch request body') expect(handlerFn).toHaveBeenCalledTimes(0) }) - it.each(INVALID_MESSAGE_IDS)('returns 400 for a batch body whose message id is %s', async (_, id) => { - const handler = createHandler() + it.each(INVALID_MESSAGE_IDS)( + 'returns 400 for a batch body whose message id is %s', + async (_, id) => { + const handler = createHandler() - const { response } = await handler.handle(createBatchRequest({ - messages: [{ ...makePeerRequestMessage('0', '/ping'), id }], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [{ ...makePeerRequestMessage('0', '/ping'), id }], + }), + ) - expect(response!.status).toBe(400) - expect(await response!.text()).toContain('Invalid batch request body') - expect(handlerFn).toHaveBeenCalledTimes(0) - }) + expect(response!.status).toBe(400) + expect(await response!.text()).toContain('Invalid batch request body') + expect(handlerFn).toHaveBeenCalledTimes(0) + }, + ) it('returns 400 for a batch body whose message id is a deeply nested array', async () => { const handler = createHandler() - const { response } = await handler.handle(new Request('https://example.com/__batch__', { - method: 'POST', - headers: { 'orpc-batch': '1', 'content-type': 'application/json' }, - body: `[{"id":${DEEPLY_NESTED_ARRAY_JSON},"kind":"request","json":{"url":"/ping"}}]`, - })) + const { response } = await handler.handle( + new Request('https://example.com/__batch__', { + method: 'POST', + headers: { 'orpc-batch': '1', 'content-type': 'application/json' }, + body: `[{"id":${DEEPLY_NESTED_ARRAY_JSON},"kind":"request","json":{"url":"/ping"}}]`, + }), + ) expect(response!.status).toBe(400) expect(await response!.text()).toContain('Invalid batch request body') @@ -278,15 +297,17 @@ describe('batchHandlerPlugin', () => { plugins: [new BatchHandlerPlugin(), new RequestCompressionHandlerPlugin()], }) - const { response } = await handler.handle(new Request('https://example.com/__batch__', { - method: 'POST', - headers: { - 'orpc-batch': '1', - 'content-type': 'application/json', - 'content-encoding': 'gzip, gzip, gzip, gzip, gzip, gzip', - }, - body: JSON.stringify([makePeerRequestMessage('0', '/ping')]), - })) + const { response } = await handler.handle( + new Request('https://example.com/__batch__', { + method: 'POST', + headers: { + 'orpc-batch': '1', + 'content-type': 'application/json', + 'content-encoding': 'gzip, gzip, gzip, gzip, gzip, gzip', + }, + body: JSON.stringify([makePeerRequestMessage('0', '/ping')]), + }), + ) expect(response!.status).toBe(400) expect(await response!.text()).toContain('Too many content encodings.') @@ -298,9 +319,11 @@ describe('batchHandlerPlugin', () => { plugins: [new BatchHandlerPlugin(), new RequestLimitHandlerPlugin({ maxBodySize: 10 })], }) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/ping')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/ping')], + }), + ) expect(response!.status).toBe(400) expect(await response!.text()).toContain(new ORPCError('PAYLOAD_TOO_LARGE').message) @@ -314,61 +337,73 @@ describe('batchHandlerPlugin', () => { makePeerRequestMessage('1', '/ping'), ] - const { response } = await handler.handle(createBatchRequest({ - messages: peerMessages, - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: peerMessages, + }), + ) expect(response!.status).toBe(413) expect(handlerFn).toHaveBeenCalledTimes(0) }) it('returns 500 sub-response when mapSubrequest throws', async ({ onTestFinished }) => { - const rejectSpy = vi.spyOn(Promise, 'reject') + const rejectSpy = vi + .spyOn(Promise, 'reject') .mockImplementation(() => new Promise(() => {}) as Promise) onTestFinished(() => { rejectSpy.mockRestore() }) - const handler = createHandler(new BatchHandlerPlugin({ - mapSubrequest: () => { - throw new Error('boom') - }, - })) + const handler = createHandler( + new BatchHandlerPlugin({ + mapSubrequest: () => { + throw new Error('boom') + }, + }), + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/ping')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/ping')], + }), + ) expect(response!.status).toBe(207) - const body = await response!.json() as any + const body = (await response!.json()) as any expect(body[0].json.status).toBe(500) expect(body[0].json.body).toBe('Internal server error') expect(rejectSpy).toHaveBeenCalledTimes(1) }) it('returns 500 sub-response when mapSubresponse throws', async ({ onTestFinished }) => { - const rejectSpy = vi.spyOn(Promise, 'reject') + const rejectSpy = vi + .spyOn(Promise, 'reject') .mockImplementation(() => new Promise(() => {}) as Promise) onTestFinished(() => { rejectSpy.mockRestore() }) - const handler = createHandler(new BatchHandlerPlugin({ - mapSubresponse: () => { - throw new Error('boom') - }, - })) + const handler = createHandler( + new BatchHandlerPlugin({ + mapSubresponse: () => { + throw new Error('boom') + }, + }), + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/ping')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/ping')], + }), + ) expect(response!.status).toBe(207) - const body = await response!.json() as any + const body = (await response!.json()) as any expect(body[0].json.status).toBe(500) expect(body[0].json.body).toBe('Internal server error') expect(rejectSpy).toHaveBeenCalledTimes(1) @@ -376,17 +411,27 @@ describe('batchHandlerPlugin', () => { describe('with a stream sub-response', () => { it.each([ - ['an event iterator', 'event-stream', (gate: Promise) => (async function* () { - yield 'first' - await gate - })()], - ['a readable stream', 'octet-stream', (gate: Promise) => new ReadableStream({ - async start(controller) { - controller.enqueue(new TextEncoder().encode('first')) - await gate - controller.close() - }, - })], + [ + 'an event iterator', + 'event-stream', + (gate: Promise) => + (async function* () { + yield 'first' + await gate + })(), + ], + [ + 'a readable stream', + 'octet-stream', + (gate: Promise) => + new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('first')) + await gate + controller.close() + }, + }), + ], ] as const)('streams the batch once a sub-response is %s', async (_, kind, createStream) => { const gate = promiseWithResolvers() const handler = createHandler(new BatchHandlerPlugin(), { @@ -395,9 +440,14 @@ describe('batchHandlerPlugin', () => { }) // The stream stays open until the gate is released, so the batch must stream to answer - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/ping'), makePeerRequestMessage('1', '/stream')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/ping'), + makePeerRequestMessage('1', '/stream'), + ], + }), + ) expect(response!.status).toBe(207) expect(response!.headers.get('standard-server')).toEqual('octet-stream') @@ -406,10 +456,10 @@ describe('batchHandlerPlugin', () => { gate.resolve() const messages = await readAllMessages(response!) - expect(messages.filter(m => m.id === '0')).toEqual([ + expect(messages.filter((m) => m.id === '0')).toEqual([ expect.objectContaining({ kind: 'response' }), ]) - expect(messages.filter(m => m.id === '1')).toEqual([ + expect(messages.filter((m) => m.id === '1')).toEqual([ expect.objectContaining({ kind: 'response' }), expect.objectContaining({ kind }), expect.objectContaining({ kind }), @@ -427,11 +477,18 @@ describe('batchHandlerPlugin', () => { }) let settled = false - const result = handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/events'), makePeerRequestMessage('1', '/file')], - })).finally(() => { - settled = true - }) + const result = handler + .handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/events'), + makePeerRequestMessage('1', '/file'), + ], + }), + ) + .finally(() => { + settled = true + }) await sleep(10) expect(settled).toBe(false) @@ -441,26 +498,35 @@ describe('batchHandlerPlugin', () => { expect(response!.headers.get('content-type')).toEqual('application/vnd.orpc.batch') const messages = await readAllMessages(response!) - expect(messages.filter(m => m.kind === 'response').map(m => m.id).sort()).toEqual(['0', '1']) - expect(new TextDecoder().decode(messages.find(m => m.id === '1').binary)).toBe('__FILE__') + expect( + messages + .filter((m) => m.kind === 'response') + .map((m) => m.id) + .toSorted((a, b) => a.localeCompare(b)), + ).toEqual(['0', '1']) + expect(new TextDecoder().decode(messages.find((m) => m.id === '1').binary)).toBe('__FILE__') }) }) it('falls back to buffered mode for an unknown batch header value', async () => { - const { response } = await createHandler().handle(createBatchRequest({ - mode: '1', - messages: [makePeerRequestMessage('0', '/ping')], - })) + const { response } = await createHandler().handle( + createBatchRequest({ + mode: '1', + messages: [makePeerRequestMessage('0', '/ping')], + }), + ) await expect(response!.json()).resolves.toHaveLength(1) }) describe('in streaming mode', () => { it('answers with an empty stream when no sub-request reaches a procedure', async () => { - const { response } = await createHandler().handle(createBatchRequest({ - mode: 'streaming', - messages: [{ kind: 'cancel', id: '0' }], - })) + const { response } = await createHandler().handle( + createBatchRequest({ + mode: 'streaming', + messages: [{ kind: 'cancel', id: '0' }], + }), + ) expect(response!.status).toBe(207) expect(response!.headers.get('content-type')).toEqual('application/vnd.orpc.batch') @@ -473,20 +539,30 @@ describe('batchHandlerPlugin', () => { const handler = createHandler(new BatchHandlerPlugin(), createSlowRouter(gate.promise)) // Answers while `slow` still waits for the gate - const { response } = await handler.handle(createBatchRequest({ - mode: 'streaming', - messages: [makePeerRequestMessage('0', '/slow'), makePeerRequestMessage('1', '/fast')], - })) + const { response } = await handler.handle( + createBatchRequest({ + mode: 'streaming', + messages: [makePeerRequestMessage('0', '/slow'), makePeerRequestMessage('1', '/fast')], + }), + ) expect(response!.status).toBe(207) expect(response!.headers.get('standard-server')).toEqual('octet-stream') expect(response!.headers.get('content-type')).toEqual('application/vnd.orpc.batch') const reader = response!.body!.getReader() - await expect(readMessage(reader)).resolves.toMatchObject({ kind: 'response', id: '1', json: { body: { json: 'fast' } } }) + await expect(readMessage(reader)).resolves.toMatchObject({ + kind: 'response', + id: '1', + json: { body: { json: 'fast' } }, + }) gate.resolve() - await expect(readMessage(reader)).resolves.toMatchObject({ kind: 'response', id: '0', json: { body: { json: 'slow' } } }) + await expect(readMessage(reader)).resolves.toMatchObject({ + kind: 'response', + id: '0', + json: { body: { json: 'slow' } }, + }) await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) @@ -508,38 +584,52 @@ describe('batchHandlerPlugin', () => { try { yield 'ready' await waitForAbort(signal!) - } - finally { - await new Promise(resolve => setTimeout(resolve)) // async cleanup + } finally { + await new Promise((resolve) => setTimeout(resolve)) // async cleanup stopped(signal!.aborted) } }), }) - const messages = [makePeerRequestMessage('0', '/wait'), makePeerRequestMessage('1', '/subscribe')] + const messages = [ + makePeerRequestMessage('0', '/wait'), + makePeerRequestMessage('1', '/subscribe'), + ] it.each([ // The stream sub-response waits for `wait`, so its generator never starts ['with', messages, 1], ['without', [makePeerRequestMessage('0', '/wait'), makePeerRequestMessage('1', '/wait')], 2], - ] as const)('aborts sub-requests %s a stream sub-response when the batch request is aborted', async (_, batchMessages, running) => { - const controller = new AbortController() - const result = handler.handle(createBatchRequest({ messages: batchMessages, signal: controller.signal })) + ] as const)( + 'aborts sub-requests %s a stream sub-response when the batch request is aborted', + async (_, batchMessages, running) => { + const controller = new AbortController() + const result = handler.handle( + createBatchRequest({ messages: batchMessages, signal: controller.signal }), + ) - await vi.waitFor(() => expect(started).toHaveBeenCalledTimes(running)) - expect(stopped).not.toHaveBeenCalled() + await vi.waitFor(() => expect(started).toHaveBeenCalledTimes(running)) + expect(stopped).not.toHaveBeenCalled() - controller.abort() + controller.abort() - await vi.waitFor(() => expect(stopped.mock.calls).toEqual(Array.from({ length: running }, () => [true]))) - await expect(result).resolves.toMatchObject({ matched: true }) - expect(started).toHaveBeenCalledTimes(running) - }) + await vi.waitFor(() => + expect(stopped.mock.calls).toEqual(Array.from({ length: running }, () => [true])), + ) + await expect(result).resolves.toMatchObject({ matched: true }) + expect(started).toHaveBeenCalledTimes(running) + }, + ) it('aborts sub-requests when the response stream is cancelled', async () => { - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/subscribe'), makePeerRequestMessage('1', '/subscribe')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/subscribe'), + makePeerRequestMessage('1', '/subscribe'), + ], + }), + ) await vi.waitFor(() => expect(started).toHaveBeenCalledTimes(2)) await response!.body!.cancel() @@ -548,10 +638,16 @@ describe('batchHandlerPlugin', () => { }) it('aborts sub-requests when the response stream of a streaming batch is cancelled before they respond', async () => { - const { response } = await handler.handle(createBatchRequest({ - mode: 'streaming', - messages: [makePeerRequestMessage('0', '/ping'), makePeerRequestMessage('1', '/wait'), makePeerRequestMessage('2', '/wait')], - })) + const { response } = await handler.handle( + createBatchRequest({ + mode: 'streaming', + messages: [ + makePeerRequestMessage('0', '/ping'), + makePeerRequestMessage('1', '/wait'), + makePeerRequestMessage('2', '/wait'), + ], + }), + ) await vi.waitFor(() => expect(started).toHaveBeenCalledTimes(2)) await response!.body!.cancel() @@ -560,10 +656,12 @@ describe('batchHandlerPlugin', () => { }) it('aborts sub-requests when the batch request is already aborted', async () => { - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/wait')], - signal: AbortSignal.abort(), - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/wait')], + signal: AbortSignal.abort(), + }), + ) await response!.arrayBuffer() expect(stopped.mock.calls).toEqual([[true]]) @@ -573,14 +671,14 @@ describe('batchHandlerPlugin', () => { describe('get batches', () => { it('handles batch GET requests via query param', async () => { const handler = createHandler() - const data = encodeURIComponent(JSON.stringify([ - makePeerRequestMessage('0', '/ping', 'GET'), - ])) + const data = encodeURIComponent(JSON.stringify([makePeerRequestMessage('0', '/ping', 'GET')])) - const { matched, response } = await handler.handle(createBatchRequest({ - method: 'GET', - data, - })) + const { matched, response } = await handler.handle( + createBatchRequest({ + method: 'GET', + data, + }), + ) expect(matched).toBe(true) expect(response!.status).toBe(207) @@ -589,10 +687,12 @@ describe('batchHandlerPlugin', () => { it('returns 400 for invalid GET data param', async () => { const handler = createHandler() - const { response } = await handler.handle(createBatchRequest({ - method: 'GET', - data: 'invalid-json', - })) + const { response } = await handler.handle( + createBatchRequest({ + method: 'GET', + data: 'invalid-json', + }), + ) expect(response!.status).toBe(400) }) @@ -600,9 +700,11 @@ describe('batchHandlerPlugin', () => { it('returns 400 when GET batch request misses data param', async () => { const handler = createHandler() - const { response } = await handler.handle(createBatchRequest({ - method: 'GET', - })) + const { response } = await handler.handle( + createBatchRequest({ + method: 'GET', + }), + ) expect(response!.status).toBe(400) expect(await response!.text()).toContain('Missing data parameter') @@ -612,39 +714,50 @@ describe('batchHandlerPlugin', () => { const handler = createHandler() const data = encodeURIComponent(JSON.stringify({ not: 'an-array' })) - const { response } = await handler.handle(createBatchRequest({ - method: 'GET', - data, - })) + const { response } = await handler.handle( + createBatchRequest({ + method: 'GET', + data, + }), + ) expect(response!.status).toBe(400) expect(await response!.text()).toContain('Invalid batch request data parameter') }) - it.each(INVALID_MESSAGE_IDS)('returns 400 for GET batch data whose message id is %s', async (_, id) => { - const handler = createHandler() - const data = encodeURIComponent(JSON.stringify([ - { ...makePeerRequestMessage('0', '/ping', 'GET'), id }, - ])) + it.each(INVALID_MESSAGE_IDS)( + 'returns 400 for GET batch data whose message id is %s', + async (_, id) => { + const handler = createHandler() + const data = encodeURIComponent( + JSON.stringify([{ ...makePeerRequestMessage('0', '/ping', 'GET'), id }]), + ) - const { response } = await handler.handle(createBatchRequest({ - method: 'GET', - data, - })) + const { response } = await handler.handle( + createBatchRequest({ + method: 'GET', + data, + }), + ) - expect(response!.status).toBe(400) - expect(await response!.text()).toContain('Invalid batch request data parameter') - expect(handlerFn).toHaveBeenCalledTimes(0) - }) + expect(response!.status).toBe(400) + expect(await response!.text()).toContain('Invalid batch request data parameter') + expect(handlerFn).toHaveBeenCalledTimes(0) + }, + ) it('returns 400 for GET batch data whose message id is a deeply nested array', async () => { const handler = createHandler() - const data = encodeURIComponent(`[{"id":${DEEPLY_NESTED_ARRAY_JSON},"kind":"request","json":{"method":"GET","url":"/ping"}}]`) + const data = encodeURIComponent( + `[{"id":${DEEPLY_NESTED_ARRAY_JSON},"kind":"request","json":{"method":"GET","url":"/ping"}}]`, + ) - const { response } = await handler.handle(createBatchRequest({ - method: 'GET', - data, - })) + const { response } = await handler.handle( + createBatchRequest({ + method: 'GET', + data, + }), + ) expect(response!.status).toBe(400) expect(await response!.text()).toContain('Invalid batch request data parameter') @@ -653,15 +766,19 @@ describe('batchHandlerPlugin', () => { it('returns 400 when a GET batch contains a non-GET sub-request', async () => { const handler = createHandler() - const data = encodeURIComponent(JSON.stringify([ - makePeerRequestMessage('0', '/ping', 'GET'), - makePeerRequestMessage('1', '/ping', 'POST'), - ])) + const data = encodeURIComponent( + JSON.stringify([ + makePeerRequestMessage('0', '/ping', 'GET'), + makePeerRequestMessage('1', '/ping', 'POST'), + ]), + ) - const { response } = await handler.handle(createBatchRequest({ - method: 'GET', - data, - })) + const { response } = await handler.handle( + createBatchRequest({ + method: 'GET', + data, + }), + ) expect(response!.status).toBe(400) expect(await response!.text()).toContain('GET batch requests only accept GET sub-requests') @@ -670,14 +787,16 @@ describe('batchHandlerPlugin', () => { it('returns 400 when a GET batch sub-request omits the method (defaults to POST)', async () => { const handler = createHandler() - const data = encodeURIComponent(JSON.stringify([ - { kind: 'request', id: '0', json: { url: '/ping', headers: {} } }, - ])) + const data = encodeURIComponent( + JSON.stringify([{ kind: 'request', id: '0', json: { url: '/ping', headers: {} } }]), + ) - const { response } = await handler.handle(createBatchRequest({ - method: 'GET', - data, - })) + const { response } = await handler.handle( + createBatchRequest({ + method: 'GET', + data, + }), + ) expect(response!.status).toBe(400) expect(await response!.text()).toContain('GET batch requests only accept GET sub-requests') @@ -689,12 +808,14 @@ describe('batchHandlerPlugin', () => { it('returns 400 before execution when a QUERY batch contains a non-request message', async () => { const handler = createHandler() - const { response } = await handler.handle(createBatchRequest({ - method: 'QUERY', - messages: [ - { kind: 'response', id: '0', json: { method: 'POST', url: '/ping', headers: {} } }, - ], - })) + const { response } = await handler.handle( + createBatchRequest({ + method: 'QUERY', + messages: [ + { kind: 'response', id: '0', json: { method: 'POST', url: '/ping', headers: {} } }, + ], + }), + ) expect(response!.status).toBe(400) expect(handlerFn).toHaveBeenCalledTimes(0) @@ -703,16 +824,20 @@ describe('batchHandlerPlugin', () => { it('returns 400 before execution when a QUERY batch contains a non-QUERY sub-request', async () => { const handler = createHandler() - const { response } = await handler.handle(createBatchRequest({ - method: 'QUERY', - messages: [ - makePeerRequestMessage('0', '/ping', 'QUERY'), - makePeerRequestMessage('1', '/ping', 'POST'), - ], - })) + const { response } = await handler.handle( + createBatchRequest({ + method: 'QUERY', + messages: [ + makePeerRequestMessage('0', '/ping', 'QUERY'), + makePeerRequestMessage('1', '/ping', 'POST'), + ], + }), + ) expect(response!.status).toBe(400) - expect(await response!.text()).toContain('QUERY batch requests only accept QUERY sub-requests') + expect(await response!.text()).toContain( + 'QUERY batch requests only accept QUERY sub-requests', + ) expect(handlerFn).toHaveBeenCalledTimes(0) }) }) @@ -723,21 +848,31 @@ describe('batchHandlerPlugin', () => { const handler = new RPCHandler(router, { plugins: [new BatchHandlerPlugin()], - interceptors: [({ next, request }) => { - seenHeaders.push(request.headers) - return next() - }], + interceptors: [ + ({ next, request }) => { + seenHeaders.push(request.headers) + return next() + }, + ], }) return { handler, seenHeaders } } - function createBatchRequestWithHeaders(headers: Record, subRequestHeaders: Record) { + function createBatchRequestWithHeaders( + headers: Record, + subRequestHeaders: Record, + ) { return new Request('https://example.com/__batch__', { method: 'POST', headers: { 'orpc-batch': '1', 'content-type': 'application/json', ...headers }, body: JSON.stringify([ - { kind: 'request', id: '0', json: { method: 'POST', url: '/ping', headers: subRequestHeaders }, binary: undefined }, + { + kind: 'request', + id: '0', + json: { method: 'POST', url: '/ping', headers: subRequestHeaders }, + binary: undefined, + }, ]), }) } @@ -745,10 +880,12 @@ describe('batchHandlerPlugin', () => { it('merges batch request headers into sub-requests and lets the batch request win', async () => { const { handler, seenHeaders } = createHeaderCapturingHandler() - await handler.handle(createBatchRequestWithHeaders( - { 'x-from-batch': 'batch', 'x-overridden': 'batch' }, - { 'x-from-sub-request': 'sub-request', 'x-overridden': 'sub-request' }, - )) + await handler.handle( + createBatchRequestWithHeaders( + { 'x-from-batch': 'batch', 'x-overridden': 'batch' }, + { 'x-from-sub-request': 'sub-request', 'x-overridden': 'sub-request' }, + ), + ) expect(seenHeaders[0]).toMatchObject({ 'x-from-batch': 'batch', @@ -761,10 +898,12 @@ describe('batchHandlerPlugin', () => { it('prevents a sub-request from spoofing a header the batch request already carries', async () => { const { handler, seenHeaders } = createHeaderCapturingHandler() - await handler.handle(createBatchRequestWithHeaders( - { authorization: 'Bearer real' }, - { authorization: 'Bearer spoofed' }, - )) + await handler.handle( + createBatchRequestWithHeaders( + { authorization: 'Bearer real' }, + { authorization: 'Bearer spoofed' }, + ), + ) expect(seenHeaders[0]!.authorization).toEqual('Bearer real') }) @@ -807,79 +946,123 @@ describe('batchHandlerPlugin', () => { } it('moves cookies and shared headers onto the batch response', async () => { - const { response } = await createHandler(undefined, headersRouter).handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/login'), makePeerRequestMessage('1', '/refresh')], - })) + const { response } = await createHandler(undefined, headersRouter).handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/login'), + makePeerRequestMessage('1', '/refresh'), + ], + }), + ) expect(response!.status).toBe(207) - expect(response!.headers.getSetCookie()).toEqual(['session=abc; HttpOnly', 'theme=dark', 'token=xyz; HttpOnly']) + expect(response!.headers.getSetCookie()).toEqual([ + 'session=abc; HttpOnly', + 'theme=dark', + 'token=xyz; HttpOnly', + ]) expect(response!.headers.get('cache-control')).toBe('max-age=60') // describes each sub-response's body, not the batch response's expect(response!.headers.get('content-language')).toBe(null) - const body = await response!.json() as any[] - expect(body.find(m => m.id === '0').json.headers).toEqual({ 'content-language': 'en', 'x-only': 'login' }) - expect(body.find(m => m.id === '1').json.headers).toEqual({ 'content-language': 'en' }) + const body = (await response!.json()) as any[] + expect(body.find((m) => m.id === '0').json.headers).toEqual({ + 'content-language': 'en', + 'x-only': 'login', + }) + expect(body.find((m) => m.id === '1').json.headers).toEqual({ 'content-language': 'en' }) }) it.each([ ['has another value', '/uncached'], ['lacks it', '/ping'], ])('keeps a header on the sub-responses when one of them %s', async (_, url) => { - const { response } = await createHandler(undefined, headersRouter).handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/login'), makePeerRequestMessage('1', url)], - })) + const { response } = await createHandler(undefined, headersRouter).handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/login'), makePeerRequestMessage('1', url)], + }), + ) expect(response!.headers.getSetCookie()).toEqual(['session=abc; HttpOnly', 'theme=dark']) expect(response!.headers.get('cache-control')).toBe(null) - const body = await response!.json() as any[] - expect(body.find(m => m.id === '0').json.headers['cache-control']).toBe('max-age=60') + const body = (await response!.json()) as any[] + expect(body.find((m) => m.id === '0').json.headers['cache-control']).toBe('max-age=60') }) it('merges cookies with, and keeps shared headers off, the ones the headers option sets', async () => { - const handler = createHandler(new BatchHandlerPlugin({ - headers: { 'set-cookie': 'batch=1', 'cache-control': 'no-cache' }, - }), headersRouter) + const handler = createHandler( + new BatchHandlerPlugin({ + headers: { 'set-cookie': 'batch=1', 'cache-control': 'no-cache' }, + }), + headersRouter, + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/login'), makePeerRequestMessage('1', '/refresh')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/login'), + makePeerRequestMessage('1', '/refresh'), + ], + }), + ) - expect(response!.headers.getSetCookie()).toEqual(['batch=1', 'session=abc; HttpOnly', 'theme=dark', 'token=xyz; HttpOnly']) + expect(response!.headers.getSetCookie()).toEqual([ + 'batch=1', + 'session=abc; HttpOnly', + 'theme=dark', + 'token=xyz; HttpOnly', + ]) expect(response!.headers.get('cache-control')).toBe('no-cache') - const body = await response!.json() as any[] - expect(body.map(m => m.json.headers['cache-control'])).toEqual(['max-age=60', 'max-age=60']) + const body = (await response!.json()) as any[] + expect(body.map((m) => m.json.headers['cache-control'])).toEqual(['max-age=60', 'max-age=60']) }) it('drops a sub-response header the batch response already has with the same value', async () => { - const handler = createHandler(new BatchHandlerPlugin({ headers: { 'x-only': 'login' } }), headersRouter) + const handler = createHandler( + new BatchHandlerPlugin({ headers: { 'x-only': 'login' } }), + headersRouter, + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/login'), makePeerRequestMessage('1', '/refresh')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/login'), + makePeerRequestMessage('1', '/refresh'), + ], + }), + ) expect(response!.headers.get('x-only')).toBe('login') - const body = await response!.json() as any[] - expect(body.find(m => m.id === '0').json.headers).toEqual({ 'content-language': 'en' }) + const body = (await response!.json()) as any[] + expect(body.find((m) => m.id === '0').json.headers).toEqual({ 'content-language': 'en' }) }) it('lets mapSubresponse keep headers on each sub-response, cookies included', async () => { const mapSubresponse = vi.fn((subresponse: StandardResponse) => subresponse) const handler = createHandler(new BatchHandlerPlugin({ mapSubresponse }), headersRouter) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/login'), makePeerRequestMessage('1', '/refresh')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/login'), + makePeerRequestMessage('1', '/refresh'), + ], + }), + ) // The batch response still carries them - expect(response!.headers.getSetCookie()).toEqual(['session=abc; HttpOnly', 'theme=dark', 'token=xyz; HttpOnly']) + expect(response!.headers.getSetCookie()).toEqual([ + 'session=abc; HttpOnly', + 'theme=dark', + 'token=xyz; HttpOnly', + ]) expect(response!.headers.get('cache-control')).toBe('max-age=60') - const body = await response!.json() as any[] - expect(body.find(m => m.id === '1').json.headers).toEqual({ + const body = (await response!.json()) as any[] + expect(body.find((m) => m.id === '1').json.headers).toEqual({ 'set-cookie': 'token=xyz; HttpOnly', 'cache-control': 'max-age=60', 'content-language': 'en', @@ -894,19 +1077,27 @@ describe('batchHandlerPlugin', () => { }) it('keeps a shared header off the batch response when the headers option sets it to undefined', async () => { - const handler = createHandler(new BatchHandlerPlugin({ headers: { 'cache-control': undefined } }), headersRouter) + const handler = createHandler( + new BatchHandlerPlugin({ headers: { 'cache-control': undefined } }), + headersRouter, + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/login'), makePeerRequestMessage('1', '/refresh')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/login'), + makePeerRequestMessage('1', '/refresh'), + ], + }), + ) expect(response!.headers.get('cache-control')).toBe(null) - const body = await response!.json() as any[] - expect(body.map(m => m.json.headers['cache-control'])).toEqual(['max-age=60', 'max-age=60']) + const body = (await response!.json()) as any[] + expect(body.map((m) => m.json.headers['cache-control'])).toEqual(['max-age=60', 'max-age=60']) }) - it('merges every sub-response\'s vary fields onto the batch response', async () => { + it("merges every sub-response's vary fields onto the batch response", async () => { const handler = createHandler(new BatchHandlerPlugin({ headers: { vary: 'Origin' } }), { localized: os.$context().handler(({ context }) => { context.resHeaders?.set('cache-control', 'public, max-age=60') @@ -924,13 +1115,15 @@ describe('batchHandlerPlugin', () => { }), }) - const { response } = await handler.handle(createBatchRequest({ - messages: [ - makePeerRequestMessage('0', '/localized'), - makePeerRequestMessage('1', '/personal'), - makePeerRequestMessage('2', '/shared'), - ], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/localized'), + makePeerRequestMessage('1', '/personal'), + makePeerRequestMessage('2', '/shared'), + ], + }), + ) expect(response!.headers.get('cache-control')).toBe('public, max-age=60') expect(response!.headers.get('vary')).toBe('Origin, Accept-Language, Cookie') @@ -948,34 +1141,49 @@ describe('batchHandlerPlugin', () => { }), }) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/any'), makePeerRequestMessage('1', '/localized')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/any'), + makePeerRequestMessage('1', '/localized'), + ], + }), + ) expect(response!.headers.get('vary')).toBe('*') }) it('sets each distinct cookie once, including the ones the headers option sets', async () => { - const handler = createHandler(new BatchHandlerPlugin({ headers: { 'set-cookie': 'theme=dark' } }), headersRouter) + const handler = createHandler( + new BatchHandlerPlugin({ headers: { 'set-cookie': 'theme=dark' } }), + headersRouter, + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/login'), makePeerRequestMessage('1', '/login')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/login'), makePeerRequestMessage('1', '/login')], + }), + ) expect(response!.headers.getSetCookie()).toEqual(['theme=dark', 'session=abc; HttpOnly']) }) it('moves cookies and shared headers onto a batch response that carries binary', async () => { - const { response } = await createHandler(undefined, headersRouter).handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/image'), makePeerRequestMessage('1', '/refresh')], - })) + const { response } = await createHandler(undefined, headersRouter).handle( + createBatchRequest({ + messages: [ + makePeerRequestMessage('0', '/image'), + makePeerRequestMessage('1', '/refresh'), + ], + }), + ) expect(response!.headers.get('content-type')).toBe('application/vnd.orpc.batch') expect(response!.headers.getSetCookie()).toEqual(['download=1', 'token=xyz; HttpOnly']) expect(response!.headers.get('cache-control')).toBe('max-age=60') const received = await readAllMessages(response!) - const image = received.find(m => m.id === '0') + const image = received.find((m) => m.id === '0') expect(JSON.stringify(received)).not.toContain('set-cookie') expect(image.json.headers['cache-control']).toBe(undefined) expect(image.json.headers['content-type']).toBe('image/png') @@ -986,21 +1194,31 @@ describe('batchHandlerPlugin', () => { ['json', ['/ping', '/refresh'], 'json'], ['binary', ['/image', '/refresh'], 'file'], ['streaming', ['/events', '/refresh'], 'octet-stream'], - ])('keeps the content type the headers option sets on a %s batch response', async (_case, urls, hint) => { - const handler = createHandler(new BatchHandlerPlugin({ headers: { 'content-type': 'application/x-custom' } }), headersRouter) + ])( + 'keeps the content type the headers option sets on a %s batch response', + async (_case, urls, hint) => { + const handler = createHandler( + new BatchHandlerPlugin({ headers: { 'content-type': 'application/x-custom' } }), + headersRouter, + ) - const { response } = await handler.handle(createBatchRequest({ - messages: urls.map((url, index) => makePeerRequestMessage(String(index), url)), - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: urls.map((url, index) => makePeerRequestMessage(String(index), url)), + }), + ) - expect(response!.headers.get('content-type')).toBe('application/x-custom') - expect(response!.headers.get('standard-server')).toBe(hint) - }) + expect(response!.headers.get('content-type')).toBe('application/x-custom') + expect(response!.headers.get('standard-server')).toBe(hint) + }, + ) it('moves cookies and shared headers onto a batch response that streams', async () => { - const { response } = await createHandler(undefined, headersRouter).handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/events'), makePeerRequestMessage('1', '/login')], - })) + const { response } = await createHandler(undefined, headersRouter).handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/events'), makePeerRequestMessage('1', '/login')], + }), + ) expect(response!.headers.get('content-type')).toBe('application/vnd.orpc.batch') expect(response!.headers.getSetCookie()).toEqual(['session=abc; HttpOnly', 'theme=dark']) @@ -1008,7 +1226,9 @@ describe('batchHandlerPlugin', () => { const received = await readAllMessages(response!) expect(JSON.stringify(received)).not.toContain('set-cookie') - expect(received.filter(m => m.kind === 'response').map(m => m.json.headers?.['cache-control'])).toEqual([undefined, undefined]) + expect( + received.filter((m) => m.kind === 'response').map((m) => m.json.headers?.['cache-control']), + ).toEqual([undefined, undefined]) }) describe('in streaming mode', () => { @@ -1025,13 +1245,18 @@ describe('batchHandlerPlugin', () => { }), }) - it('moves only the first sub-response\'s cookies onto the batch response', async () => { + it("moves only the first sub-response's cookies onto the batch response", async () => { const gate = promiseWithResolvers() - const { response } = await createHandler(undefined, createLaterRouter(gate.promise)).handle(createBatchRequest({ - mode: 'streaming', - messages: [makePeerRequestMessage('0', '/later'), makePeerRequestMessage('1', '/login')], - })) + const { response } = await createHandler(undefined, createLaterRouter(gate.promise)).handle( + createBatchRequest({ + mode: 'streaming', + messages: [ + makePeerRequestMessage('0', '/later'), + makePeerRequestMessage('1', '/login'), + ], + }), + ) expect(response!.headers.get('content-type')).toBe('application/vnd.orpc.batch') expect(response!.headers.getSetCookie()).toEqual(['session=abc; HttpOnly', 'theme=dark']) @@ -1040,34 +1265,57 @@ describe('batchHandlerPlugin', () => { gate.resolve() const received = await readAllMessages(response!) expect(JSON.stringify(received)).not.toContain('set-cookie') - expect(received.find(m => m.id === '0').json.headers).toEqual({ 'cache-control': 'max-age=60' }) - expect(received.find(m => m.id === '1').json.headers).toEqual({ 'cache-control': 'max-age=60', 'content-language': 'en', 'x-only': 'login' }) + expect(received.find((m) => m.id === '0').json.headers).toEqual({ + 'cache-control': 'max-age=60', + }) + expect(received.find((m) => m.id === '1').json.headers).toEqual({ + 'cache-control': 'max-age=60', + 'content-language': 'en', + 'x-only': 'login', + }) }) - it('merges the first sub-response\'s cookies with the headers option, and passes them to mapSubresponse', async () => { + it("merges the first sub-response's cookies with the headers option, and passes them to mapSubresponse", async () => { const gate = promiseWithResolvers() const mapSubresponse = vi.fn((subresponse: StandardResponse) => subresponse) - const handler = createHandler(new BatchHandlerPlugin({ - headers: { 'set-cookie': 'batch=1', 'cache-control': 'no-cache' }, - mapSubresponse, - }), createLaterRouter(gate.promise)) + const handler = createHandler( + new BatchHandlerPlugin({ + headers: { 'set-cookie': 'batch=1', 'cache-control': 'no-cache' }, + mapSubresponse, + }), + createLaterRouter(gate.promise), + ) - const { response } = await handler.handle(createBatchRequest({ - mode: 'streaming', - messages: [makePeerRequestMessage('0', '/later'), makePeerRequestMessage('1', '/refresh')], - })) + const { response } = await handler.handle( + createBatchRequest({ + mode: 'streaming', + messages: [ + makePeerRequestMessage('0', '/later'), + makePeerRequestMessage('1', '/refresh'), + ], + }), + ) expect(response!.headers.getSetCookie()).toEqual(['batch=1', 'token=xyz; HttpOnly']) expect(response!.headers.get('cache-control')).toBe('no-cache') gate.resolve() const received = await readAllMessages(response!) - expect(received.find(m => m.id === '0').json.headers).toEqual({ 'set-cookie': 'later=1', 'cache-control': 'max-age=60' }) + expect(received.find((m) => m.id === '0').json.headers).toEqual({ + 'set-cookie': 'later=1', + 'cache-control': 'max-age=60', + }) expect(mapSubresponse).toHaveBeenCalledTimes(2) expect(mapSubresponse).toHaveBeenCalledWith( expect.objectContaining({ status: 200 }), - { status: 207, headers: { 'set-cookie': ['batch=1', 'token=xyz; HttpOnly'], 'cache-control': 'no-cache' } }, + { + status: 207, + headers: { + 'set-cookie': ['batch=1', 'token=xyz; HttpOnly'], + 'cache-control': 'no-cache', + }, + }, expect.objectContaining({ request: expect.objectContaining({ url: '/__batch__' }) }), ) }) @@ -1079,9 +1327,11 @@ describe('batchHandlerPlugin', () => { const handler = createHandler(new BatchHandlerPlugin({ successStatus: 200 })) const peerMessages = [makePeerRequestMessage('0', '/ping')] - const { response } = await handler.handle(createBatchRequest({ - messages: peerMessages, - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: peerMessages, + }), + ) expect(response!.status).toBe(200) }) @@ -1108,17 +1358,22 @@ describe('batchHandlerPlugin', () => { it('sends zero-length keep-alive frames while the batch stream is idle', async () => { const gate = promiseWithResolvers() - const handler = createHandler(new BatchHandlerPlugin({ - keepAlive: { enabled: true, interval: 100 }, - }), { - events: os.handler(async function* () { - await gate.promise + const handler = createHandler( + new BatchHandlerPlugin({ + keepAlive: { enabled: true, interval: 100 }, }), - }) + { + events: os.handler(async function* () { + await gate.promise + }), + }, + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/events')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/events')], + }), + ) const reader = (response!.body as ReadableStream).getReader() @@ -1127,7 +1382,10 @@ describe('batchHandlerPlugin', () => { for (let i = 0; i < 2; i++) { const keepAlive = reader.read() await vi.advanceTimersByTimeAsync(100) - await expect(keepAlive).resolves.toEqual({ done: false, value: new Uint8Array([0, 0, 0, 0]) }) + await expect(keepAlive).resolves.toEqual({ + done: false, + value: new Uint8Array([0, 0, 0, 0]), + }) } gate.resolve() @@ -1139,13 +1397,18 @@ describe('batchHandlerPlugin', () => { }) it('does not schedule a keep-alive timer when disabled', async () => { - const handler = createHandler(new BatchHandlerPlugin({ - keepAlive: { enabled: false }, - }), eventsRouter) + const handler = createHandler( + new BatchHandlerPlugin({ + keepAlive: { enabled: false }, + }), + eventsRouter, + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/events')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/events')], + }), + ) expect(response!.body).toBeInstanceOf(ReadableStream) expect(vi.getTimerCount()).toBe(0) @@ -1156,13 +1419,18 @@ describe('batchHandlerPlugin', () => { }) it('stops keep-alive after the stream is cancelled', async () => { - const handler = createHandler(new BatchHandlerPlugin({ - keepAlive: { enabled: true, interval: 50 }, - }), idleRouter) + const handler = createHandler( + new BatchHandlerPlugin({ + keepAlive: { enabled: true, interval: 50 }, + }), + idleRouter, + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/idle')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/idle')], + }), + ) expect(vi.getTimerCount()).toBeGreaterThan(0) @@ -1180,16 +1448,20 @@ describe('batchHandlerPlugin', () => { it('clears keep-alive timer when keep-alive enqueue fails', async ({ onTestFinished }) => { const originalEnqueue = ReadableStreamDefaultController.prototype.enqueue - const enqueueSpy = vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue') - .mockImplementation(function (this: ReadableStreamDefaultController, chunk: Uint8Array) { + const enqueueSpy = vi + .spyOn(ReadableStreamDefaultController.prototype, 'enqueue') + .mockImplementation(function ( + this: ReadableStreamDefaultController, + chunk: Uint8Array, + ) { // Keep-alive frame is a zero-length length prefix: [0, 0, 0, 0] if ( - chunk instanceof Uint8Array - && chunk.byteLength === 4 - && chunk[0] === 0 - && chunk[1] === 0 - && chunk[2] === 0 - && chunk[3] === 0 + chunk instanceof Uint8Array && + chunk.byteLength === 4 && + chunk[0] === 0 && + chunk[1] === 0 && + chunk[2] === 0 && + chunk[3] === 0 ) { throw new Error('keep-alive enqueue failed') } @@ -1201,13 +1473,18 @@ describe('batchHandlerPlugin', () => { enqueueSpy.mockRestore() }) - const handler = createHandler(new BatchHandlerPlugin({ - keepAlive: { enabled: true, interval: 50 }, - }), idleRouter) + const handler = createHandler( + new BatchHandlerPlugin({ + keepAlive: { enabled: true, interval: 50 }, + }), + idleRouter, + ) - await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/idle')], - })) + await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/idle')], + }), + ) // One keep-alive setInterval is scheduled while the stream is idle. expect(vi.getTimerCount()).toBe(1) @@ -1219,20 +1496,26 @@ describe('batchHandlerPlugin', () => { }) it('clears keep-alive timer when peer-message enqueue throws', async ({ onTestFinished }) => { - const enqueueSpy = vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue') + const enqueueSpy = vi + .spyOn(ReadableStreamDefaultController.prototype, 'enqueue') .mockThrow(new Error('enqueue failed')) onTestFinished(() => { enqueueSpy.mockRestore() }) - const handler = createHandler(new BatchHandlerPlugin({ - keepAlive: { enabled: false }, - }), eventsRouter) + const handler = createHandler( + new BatchHandlerPlugin({ + keepAlive: { enabled: false }, + }), + eventsRouter, + ) - const { response } = await handler.handle(createBatchRequest({ - messages: [makePeerRequestMessage('0', '/events')], - })) + const { response } = await handler.handle( + createBatchRequest({ + messages: [makePeerRequestMessage('0', '/events')], + }), + ) const reader = (response!.body as ReadableStream).getReader() await expect(reader.read()).rejects.toThrow('enqueue failed') diff --git a/packages/server/src/plugins/batch.ts b/packages/server/src/plugins/batch.ts index b187d0a5c7..cd37ccc89d 100644 --- a/packages/server/src/plugins/batch.ts +++ b/packages/server/src/plugins/batch.ts @@ -1,13 +1,24 @@ -import type { Promisable, Value } from '@orpc/shared' -import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@standard-server/core' -import type { ClientPeerSendMessage, ServerPeerSendMessage } from '@standard-server/peer' -import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions } from '../adapters/standard' -import type { Context } from '../context' import { ORPCError } from '@orpc/client' import { BATCH_HEADER, DEFAULT_BATCH_MAX_SIZE } from '@orpc/client/plugins' +import type { Promisable, Value } from '@orpc/shared' import { isAsyncIteratorObject, promiseWithResolvers, toArray, value } from '@orpc/shared' +import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@standard-server/core' import { flattenStandardHeader, parseStandardUrl } from '@standard-server/core' -import { encodePeerMessage, isClientPeerSendMessage, isPeerMessage, ServerPeer } from '@standard-server/peer' +import type { ClientPeerSendMessage, ServerPeerSendMessage } from '@standard-server/peer' +import { + encodePeerMessage, + isClientPeerSendMessage, + isPeerMessage, + ServerPeer, +} from '@standard-server/peer' + +import type { + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, + StandardHandlerRoutingInterceptorOptions, +} from '../adapters/standard/index.ts' +import type { Context } from '../context.ts' /** * Content type for batch responses that use the length-prefixed binary framing, @@ -48,7 +59,10 @@ function isBodyHeader(name: string): boolean { * `isPeerMessage` must run first, since `isClientPeerSendMessage` does not check `id` or `binary`. */ function isClientPeerSendMessages(maybe: unknown): maybe is ClientPeerSendMessage[] { - return Array.isArray(maybe) && maybe.every((m: unknown) => isPeerMessage(m) && isClientPeerSendMessage(m)) + return ( + Array.isArray(maybe) && + maybe.every((m: unknown) => isPeerMessage(m) && isClientPeerSendMessage(m)) + ) } /** @@ -57,7 +71,7 @@ function isClientPeerSendMessages(maybe: unknown): maybe is ClientPeerSendMessag * for global logging or error handling. */ function internalError(err: unknown): StandardResponse { - Promise.reject(err) + void Promise.reject(err) return { status: 500, headers: {}, body: 'Internal server error' } } @@ -73,10 +87,12 @@ interface SubresponseHead { /** * Resolves with the first subresponse head, or `undefined` if none has one. */ -function firstSubresponseHead(heads: Promise[]): Promise { +function firstSubresponseHead( + heads: Promise[], +): Promise { return new Promise((resolve) => { for (const promise of heads) { - void promise.then(head => head && resolve(head)) + void promise.then((head) => head && resolve(head)) } void Promise.all(heads).then(() => resolve(undefined)) @@ -87,7 +103,7 @@ function firstSubresponseHead(heads: Promise[]): Pr * Sets the distinct cookies of every source on the headers. */ function liftCookies(headers: StandardHeaders, sources: StandardHeaders[]): StandardHeaders { - const cookies = new Set(sources.flatMap(source => toArray(source['set-cookie']))) + const cookies = new Set(sources.flatMap((source) => toArray(source['set-cookie']))) return cookies.size > 0 ? { ...headers, 'set-cookie': [...cookies] } : headers } @@ -111,7 +127,7 @@ function liftSubresponseHeaders( const flattened = flattenStandardHeader(value) - if (rest.every(other => flattenStandardHeader(other[name]) === flattened)) { + if (rest.every((other) => flattenStandardHeader(other[name]) === flattened)) { shared[name] = value } } @@ -119,7 +135,9 @@ function liftSubresponseHeaders( const headers = liftCookies({ ...shared, ...batchHeaders }, [batchHeaders, ...subresponseHeaders]) const varies = new Map() - for (const value of [batchHeaders, ...subresponseHeaders].flatMap(other => toArray(other.vary))) { + for (const value of [batchHeaders, ...subresponseHeaders].flatMap((other) => + toArray(other.vary), + )) { for (const field of value.split(',')) { const name = field.trim() const key = name.toLowerCase() @@ -145,7 +163,9 @@ export interface BatchHandlerPluginOptions { * @default DEFAULT_BATCH_MAX_SIZE (10) * @see {@link https://orpc.dev/docs/plugins/batch#max-size | Batch Plugin - Max Size} */ - maxSize?: Value, [options: StandardHandlerRoutingInterceptorOptions]> | undefined + maxSize?: + | Value, [options: StandardHandlerRoutingInterceptorOptions]> + | undefined /** * Map each subrequest in the batch before it is processed. @@ -153,7 +173,12 @@ export interface BatchHandlerPluginOptions { * @default merges the batch request headers into the subrequest, giving them priority over * the subrequest headers, and removes the `orpc-batch` header to prevent nested batching */ - mapSubrequest?: ((subrequest: StandardLazyRequest, batchOptions: StandardHandlerRoutingInterceptorOptions) => StandardLazyRequest) | undefined + mapSubrequest?: + | (( + subrequest: StandardLazyRequest, + batchOptions: StandardHandlerRoutingInterceptorOptions, + ) => StandardLazyRequest) + | undefined /** * Map each sub-response in the batch before it is sent. @@ -161,18 +186,22 @@ export interface BatchHandlerPluginOptions { * @default removes the headers the batch response already has with the same value, which the client * merges back, and `set-cookie`, which the browser ignores there and JavaScript could read */ - mapSubresponse?: (( - subresponse: StandardResponse, - partialBatchResponse: Pick, - batchOptions: StandardHandlerRoutingInterceptorOptions, - ) => StandardResponse) | undefined + mapSubresponse?: + | (( + subresponse: StandardResponse, + partialBatchResponse: Pick, + batchOptions: StandardHandlerRoutingInterceptorOptions, + ) => StandardResponse) + | undefined /** * Success batch response status code. * * @default 207 */ - successStatus?: Value, [batchOptions: StandardHandlerRoutingInterceptorOptions]> | undefined + successStatus?: + | Value, [batchOptions: StandardHandlerRoutingInterceptorOptions]> + | undefined /** * Success batch response headers. @@ -183,7 +212,12 @@ export interface BatchHandlerPluginOptions { * * @default {} */ - headers?: Value, [batchOptions: StandardHandlerRoutingInterceptorOptions]> | undefined + headers?: + | Value< + Promisable, + [batchOptions: StandardHandlerRoutingInterceptorOptions] + > + | undefined /** * Keep-alive settings for batch responses that stream. @@ -193,20 +227,22 @@ export interface BatchHandlerPluginOptions { * * @default { enabled: true, interval: 15000 } */ - keepAlive?: { - /** - * If true, a keep-alive frame is sent periodically while the stream is idle. - * - * @default true - */ - enabled: boolean - /** - * Interval (in milliseconds) between keep-alive frames after the last message. - * - * @default 15000 - */ - interval?: number | undefined - } | undefined + keepAlive?: + | { + /** + * If true, a keep-alive frame is sent periodically while the stream is idle. + * + * @default true + */ + enabled: boolean + /** + * Interval (in milliseconds) between keep-alive frames after the last message. + * + * @default 15000 + */ + interval?: number | undefined + } + | undefined } /** @@ -233,7 +269,10 @@ export class BatchHandlerPlugin implements StandardHandlerPlu private readonly maxSize: Exclude['maxSize'], undefined> private readonly mapSubrequest: Exclude['mapSubrequest'], undefined> - private readonly mapSubresponse: Exclude['mapSubresponse'], undefined> + private readonly mapSubresponse: Exclude< + BatchHandlerPluginOptions['mapSubresponse'], + undefined + > private readonly successStatus: Exclude['successStatus'], undefined> private readonly headers: Exclude['headers'], undefined> private readonly keepAliveEnabled: boolean @@ -242,31 +281,38 @@ export class BatchHandlerPlugin implements StandardHandlerPlu constructor(options: BatchHandlerPluginOptions = {}) { this.maxSize = options.maxSize ?? DEFAULT_BATCH_MAX_SIZE - this.mapSubrequest = options.mapSubrequest ?? ((subRequest, { request: batchRequest }) => ({ - ...subRequest, - headers: { - ...subRequest.headers, - /** - * The batch request headers win over the subrequest ones. They are the only ones the - * transport actually saw, so headers the browser injects on its own, such as `cookie` - * or `origin`, cannot be overridden by a subrequest, which is just request payload. - */ - ...batchRequest.headers, - [BATCH_HEADER]: undefined, // useful in case batch plugin is used multiple times - }, - })) - - this.mapSubresponse = options.mapSubresponse ?? ((subresponse, { headers: batchHeaders }) => { - const headers: StandardHeaders = {} - - for (const [name, value] of Object.entries(subresponse.headers)) { - if (name !== 'set-cookie' && flattenStandardHeader(value) !== flattenStandardHeader(batchHeaders[name])) { - headers[name] = value + this.mapSubrequest = + options.mapSubrequest ?? + ((subRequest, { request: batchRequest }) => ({ + ...subRequest, + headers: { + ...subRequest.headers, + /** + * The batch request headers win over the subrequest ones. They are the only ones the + * transport actually saw, so headers the browser injects on its own, such as `cookie` + * or `origin`, cannot be overridden by a subrequest, which is just request payload. + */ + ...batchRequest.headers, + [BATCH_HEADER]: undefined, // useful in case batch plugin is used multiple times + }, + })) + + this.mapSubresponse = + options.mapSubresponse ?? + ((subresponse, { headers: batchHeaders }) => { + const headers: StandardHeaders = {} + + for (const [name, value] of Object.entries(subresponse.headers)) { + if ( + name !== 'set-cookie' && + flattenStandardHeader(value) !== flattenStandardHeader(batchHeaders[name]) + ) { + headers[name] = value + } } - } - return { ...subresponse, headers } - }) + return { ...subresponse, headers } + }) this.successStatus = options.successStatus ?? 207 this.headers = options.headers ?? {} @@ -295,7 +341,11 @@ export class BatchHandlerPlugin implements StandardHandlerPlu if (!data) { return { matched: true, - response: { status: 400, headers: {}, body: 'Missing data parameter for batch request' }, + response: { + status: 400, + headers: {}, + body: 'Missing data parameter for batch request', + }, } } @@ -309,8 +359,7 @@ export class BatchHandlerPlugin implements StandardHandlerPlu } messages = mightBeMessages - } - else { + } else { const mightBeMessages = await interceptorOptions.request.resolveBody() if (!isClientPeerSendMessages(mightBeMessages)) { @@ -322,8 +371,7 @@ export class BatchHandlerPlugin implements StandardHandlerPlu messages = mightBeMessages } - } - catch (error) { + } catch (error) { return { matched: true, response: { @@ -336,12 +384,18 @@ export class BatchHandlerPlugin implements StandardHandlerPlu const outerMethod = interceptorOptions.request.method if ( - (outerMethod === 'GET' || outerMethod === 'QUERY') - && messages.some(message => message.kind === 'request' && message.json.method !== outerMethod) + (outerMethod === 'GET' || outerMethod === 'QUERY') && + messages.some( + (message) => message.kind === 'request' && message.json.method !== outerMethod, + ) ) { return { matched: true, - response: { status: 400, headers: {}, body: `${outerMethod} batch requests only accept ${outerMethod} sub-requests` }, + response: { + status: 400, + headers: {}, + body: `${outerMethod} batch requests only accept ${outerMethod} sub-requests`, + }, } } @@ -350,7 +404,11 @@ export class BatchHandlerPlugin implements StandardHandlerPlu if (messages.length > maxSize) { return { matched: true, - response: { status: 413, headers: {}, body: 'Batch request size exceeds the maximum allowed size' }, + response: { + status: 413, + headers: {}, + body: 'Batch request size exceeds the maximum allowed size', + }, } } @@ -366,16 +424,19 @@ export class BatchHandlerPlugin implements StandardHandlerPlu const ready = messages.map(() => promiseWithResolvers()) const heads = ready.map(({ promise }) => promise) const lifting = streaming - ? firstSubresponseHead(heads).then(head => ({ + ? firstSubresponseHead(heads).then((head) => ({ batchHeaders: liftCookies(headers, [headers, head?.headers ?? {}]), streams: true, })) : Promise.all(heads).then((all) => { - const present = all.filter(head => head !== undefined) + const present = all.filter((head) => head !== undefined) return { - batchHeaders: liftSubresponseHeaders(headers, present.map(head => head.headers)), - streams: present.some(head => head.stream), + batchHeaders: liftSubresponseHeaders( + headers, + present.map((head) => head.headers), + ), + streams: present.some((head) => head.stream), } }) @@ -396,8 +457,7 @@ export class BatchHandlerPlugin implements StandardHandlerPlu try { // Zero-length length-prefixed frame = keep-alive (ignored by clients) streamController!.enqueue(encodeLengthPrefix(0)) - } - catch { + } catch { // Stream may already be closed or errored. clearKeepAlive() } @@ -414,8 +474,7 @@ export class BatchHandlerPlugin implements StandardHandlerPlu const peer = new ServerPeer(async (message) => { if (streamController === undefined) { bufferedMessages.push(message) - } - else { + } else { await enqueueMessage(message) } }) @@ -429,9 +488,10 @@ export class BatchHandlerPlugin implements StandardHandlerPlu try { request = this.mapSubrequest(request, interceptorOptions) const result = await interceptorOptions.next({ ...interceptorOptions, request }) - response = result.matched ? result.response : { status: 404, headers: {}, body: 'No procedure matched' } - } - catch (err) { + response = result.matched + ? result.response + : { status: 404, headers: {}, body: 'No procedure matched' } + } catch (err) { response = internalError(err) } @@ -442,46 +502,55 @@ export class BatchHandlerPlugin implements StandardHandlerPlu const { batchHeaders } = await lifting try { - return this.mapSubresponse(response, { status, headers: batchHeaders }, interceptorOptions) - } - catch (err) { + return this.mapSubresponse( + response, + { status, headers: batchHeaders }, + interceptorOptions, + ) + } catch (err) { return internalError(err) } } const runSubrequests = async (): Promise => { - const promise = Promise.all(messages.map((msg, i) => { - const { resolve } = ready[i]! - - // Not every message reaches the handler, such as a cancel or a repeated id - return peer.message(msg, request => handleIndividualRequest(request, resolve)).finally(() => resolve(undefined)) - })) + const promise = Promise.all( + messages.map((msg, i) => { + const { resolve } = ready[i]! + + // Not every message reaches the handler, such as a cancel or a repeated id + return peer + .message(msg, (request) => handleIndividualRequest(request, resolve)) + .finally(() => resolve(undefined)) + }), + ) const signal = interceptorOptions.request.signal const closePeer = () => peer.close(signal?.reason) if (signal?.aborted) { - closePeer() + void closePeer() } signal?.addEventListener('abort', closePeer) try { await promise - } - finally { + } finally { signal?.removeEventListener('abort', closePeer) } } const subrequests = runSubrequests() const { batchHeaders, streams } = await lifting - const framedHeaders = { ...batchHeaders, 'content-type': batchHeaders['content-type'] ?? BATCH_CONTENT_TYPE } + const framedHeaders = { + ...batchHeaders, + 'content-type': batchHeaders['content-type'] ?? BATCH_CONTENT_TYPE, + } if (!streams) { await subrequests await peer.close() - if (bufferedMessages.some(msg => msg.binary !== undefined)) { + if (bufferedMessages.some((msg) => msg.binary !== undefined)) { const chunks = (await Promise.all(bufferedMessages.map(encodeFrame))).flat() return { diff --git a/packages/server/src/plugins/cors.test.ts b/packages/server/src/plugins/cors.test.ts index bb2ef8cbb0..07111808f3 100644 --- a/packages/server/src/plugins/cors.test.ts +++ b/packages/server/src/plugins/cors.test.ts @@ -1,6 +1,6 @@ -import { RPCHandler } from '../adapters/fetch/rpc-handler' -import { os } from '../builder' -import { CORSHandlerPlugin } from './cors' +import { RPCHandler } from '../adapters/fetch/rpc-handler.ts' +import { os } from '../builder.ts' +import { CORSHandlerPlugin } from './cors.ts' beforeEach(() => { vi.clearAllMocks() @@ -17,18 +17,22 @@ describe('corsHandlerPlugin', () => { plugins: [new CORSHandlerPlugin()], }) - const { response } = await handler.handle(new Request('https://example.com', { - method: 'OPTIONS', - headers: { - origin: 'https://example.com', - }, - })) + const { response } = await handler.handle( + new Request('https://example.com', { + method: 'OPTIONS', + headers: { + origin: 'https://example.com', + }, + }), + ) expect(handlerFn).toHaveBeenCalledTimes(0) expect(response!.status).toBe(204) expect(response!.headers.get('access-control-allow-origin')).toBe('*') expect(response!.headers.get('vary')).toBeNull() - expect(response!.headers.get('access-control-allow-methods')).toBe('GET, HEAD, PUT, POST, DELETE, PATCH, QUERY') + expect(response!.headers.get('access-control-allow-methods')).toBe( + 'GET, HEAD, PUT, POST, DELETE, PATCH, QUERY', + ) expect(response!.headers.get('access-control-max-age')).toBeNull() }) @@ -42,16 +46,22 @@ describe('corsHandlerPlugin', () => { plugins: [plugin], }) - const { response } = await handler.handle(new Request('https://example.com/test', { - method: 'OPTIONS', - headers: { - origin: 'https://example.com', - }, - })) + const { response } = await handler.handle( + new Request('https://example.com/test', { + method: 'OPTIONS', + headers: { + origin: 'https://example.com', + }, + }), + ) expect(response!.headers.get('access-control-max-age')).toBe('600') - expect(response!.headers.get('access-control-allow-methods')).toBe('GET, HEAD, PUT, POST, DELETE, PATCH, QUERY') - expect(response!.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization') + expect(response!.headers.get('access-control-allow-methods')).toBe( + 'GET, HEAD, PUT, POST, DELETE, PATCH, QUERY', + ) + expect(response!.headers.get('access-control-allow-headers')).toBe( + 'Content-Type, Authorization', + ) }) it('omits allow-methods and allow-headers when configured as empty', async () => { @@ -64,13 +74,15 @@ describe('corsHandlerPlugin', () => { plugins: [plugin], }) - const { response } = await handler.handle(new Request('https://example.com', { - method: 'OPTIONS', - headers: { - 'origin': 'https://example.com', - 'access-control-request-headers': 'X-Requested-With', - }, - })) + const { response } = await handler.handle( + new Request('https://example.com', { + method: 'OPTIONS', + headers: { + 'origin': 'https://example.com', + 'access-control-request-headers': 'X-Requested-With', + }, + }), + ) expect(response!.headers.get('access-control-allow-methods')).toBeNull() expect(response!.headers.get('access-control-allow-headers')).toBeNull() @@ -81,18 +93,20 @@ describe('corsHandlerPlugin', () => { plugins: [new CORSHandlerPlugin({ allowMethods: ['POST'] })], }) - const { response } = await handler.handle(new Request('https://example.com', { - method: 'OPTIONS', - headers: { - origin: 'https://example.com', - }, - })) + const { response } = await handler.handle( + new Request('https://example.com', { + method: 'OPTIONS', + headers: { + origin: 'https://example.com', + }, + }), + ) expect(response!.headers.get('access-control-allow-methods')).toBe('POST') }) it('sets allowed origin only when custom origin function approves', async () => { - const customOrigin = (origin: string) => origin === 'https://allowed.com' ? origin : null + const customOrigin = (origin: string) => (origin === 'https://allowed.com' ? origin : null) const customRouter = { custom: os.handler(() => 'ok'), } @@ -103,30 +117,34 @@ describe('corsHandlerPlugin', () => { }) // Request from allowed origin - const { response } = await handler.handle(new Request('https://example.com/custom', { - method: 'POST', - headers: { - 'origin': 'https://allowed.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/custom', { + method: 'POST', + headers: { + 'origin': 'https://allowed.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('access-control-allow-origin')).toBe('https://allowed.com') // Request from a disallowed origin should not get the header set - const { response: response2 } = await handler.handle(new Request('https://example.com/custom', { - method: 'POST', - headers: { - 'origin': 'https://disallowed.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response: response2 } = await handler.handle( + new Request('https://example.com/custom', { + method: 'POST', + headers: { + 'origin': 'https://disallowed.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response2!.headers.get('access-control-allow-origin')).toBeNull() }) it('handles timingOrigin option correctly', async () => { - const customTimingOrigin = (origin: string) => origin === 'https://timing.com' ? origin : null + const customTimingOrigin = (origin: string) => (origin === 'https://timing.com' ? origin : null) const customRouter = { timing: os.handler(() => 'ok'), } @@ -137,27 +155,31 @@ describe('corsHandlerPlugin', () => { }) // Request with allowed timing origin - const { response } = await handler.handle(new Request('https://example.com/timing', { - method: 'POST', - headers: { - 'origin': 'https://timing.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/timing', { + method: 'POST', + headers: { + 'origin': 'https://timing.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('timing-allow-origin')).toBe('https://timing.com') expect(response!.headers.get('vary')).toBe('Origin') // Request with not allowed timing origin should not have the header, // but a function may answer differently per origin so the response still varies - const { response: response2 } = await handler.handle(new Request('https://example.com/timing', { - method: 'POST', - headers: { - 'origin': 'https://not-timing.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response: response2 } = await handler.handle( + new Request('https://example.com/timing', { + method: 'POST', + headers: { + 'origin': 'https://not-timing.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response2!.headers.get('timing-allow-origin')).toBeNull() expect(response2!.headers.get('vary')).toBe('Origin') }) @@ -172,16 +194,20 @@ describe('corsHandlerPlugin', () => { plugins: [plugin], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://example.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://example.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('access-control-allow-credentials')).toBe('true') - expect(response!.headers.get('access-control-expose-headers')).toBe('X-Custom-Header, X-Another-Header') + expect(response!.headers.get('access-control-expose-headers')).toBe( + 'X-Custom-Header, X-Another-Header', + ) }) it('returns "*" for access-control-allow-origin when origin function returns "*"', async () => { @@ -190,14 +216,16 @@ describe('corsHandlerPlugin', () => { plugins: [plugin], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://any-origin.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://any-origin.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('access-control-allow-origin')).toBe('*') // a function may answer differently per origin, so the response is always marked as varying expect(response!.headers.get('vary')).toBe('Origin') @@ -209,14 +237,16 @@ describe('corsHandlerPlugin', () => { plugins: [plugin], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://any-origin.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://any-origin.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('timing-allow-origin')).toBe('*') expect(response!.headers.get('vary')).toBe('Origin') }) @@ -227,26 +257,30 @@ describe('corsHandlerPlugin', () => { plugins: [plugin], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://timing.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://timing.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('access-control-allow-origin')).toBe('*') expect(response!.headers.get('timing-allow-origin')).toBe('https://timing.com') expect(response!.headers.get('vary')).toBe('Origin') // a request without an origin gets no timing header, but must still be marked as varying by origin - const { response: response2 } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response: response2 } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response2!.headers.get('access-control-allow-origin')).toBe('*') expect(response2!.headers.get('timing-allow-origin')).toBeNull() expect(response2!.headers.get('vary')).toBe('Origin') @@ -258,14 +292,16 @@ describe('corsHandlerPlugin', () => { plugins: [plugin], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://timing.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://timing.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('timing-allow-origin')).toBeNull() expect(response!.headers.get('vary')).toBeNull() }) @@ -275,15 +311,19 @@ describe('corsHandlerPlugin', () => { plugins: [new CORSHandlerPlugin()], }) - const { response } = await handler.handle(new Request('https://example.com', { - method: 'OPTIONS', - headers: { - 'origin': 'https://example.com', - 'access-control-request-headers': 'X-Requested-With, Content-Type', - }, - })) + const { response } = await handler.handle( + new Request('https://example.com', { + method: 'OPTIONS', + headers: { + 'origin': 'https://example.com', + 'access-control-request-headers': 'X-Requested-With, Content-Type', + }, + }), + ) - expect(response!.headers.get('access-control-allow-headers')).toBe('X-Requested-With, Content-Type') + expect(response!.headers.get('access-control-allow-headers')).toBe( + 'X-Requested-With, Content-Type', + ) }) it('only runs origin functions for requests that carry an Origin header', async () => { @@ -293,13 +333,15 @@ describe('corsHandlerPlugin', () => { plugins: [new CORSHandlerPlugin({ origin: originFn, timingOrigin: timingOriginFn })], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) // nothing to reflect, but the response still varies by origin so caches keep it apart from allowed origins expect(originFn).not.toHaveBeenCalled() @@ -308,17 +350,25 @@ describe('corsHandlerPlugin', () => { expect(response!.headers.get('timing-allow-origin')).toBeNull() expect(response!.headers.get('vary')).toBe('Origin') - const { response: response2 } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://app.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) - - expect(originFn).toHaveBeenCalledExactlyOnceWith('https://app.com', expect.objectContaining({ request: expect.any(Object) })) - expect(timingOriginFn).toHaveBeenCalledExactlyOnceWith('https://app.com', expect.objectContaining({ request: expect.any(Object) })) + const { response: response2 } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://app.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) + + expect(originFn).toHaveBeenCalledExactlyOnceWith( + 'https://app.com', + expect.objectContaining({ request: expect.any(Object) }), + ) + expect(timingOriginFn).toHaveBeenCalledExactlyOnceWith( + 'https://app.com', + expect.objectContaining({ request: expect.any(Object) }), + ) expect(response2!.headers.get('access-control-allow-origin')).toBe('https://app.com') expect(response2!.headers.get('timing-allow-origin')).toBe('https://app.com') expect(response2!.headers.get('vary')).toBe('Origin') @@ -329,13 +379,15 @@ describe('corsHandlerPlugin', () => { plugins: [new CORSHandlerPlugin({ timingOrigin: '*' })], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('access-control-allow-origin')).toBe('*') expect(response!.headers.get('timing-allow-origin')).toBe('*') @@ -344,64 +396,72 @@ describe('corsHandlerPlugin', () => { it('supports an async origin function resolved per request', async () => { const plugin = new CORSHandlerPlugin({ - origin: async origin => origin === 'https://allowed.com' ? origin : null, + origin: async (origin) => (origin === 'https://allowed.com' ? origin : null), }) const handler = new RPCHandler(router, { plugins: [plugin], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://allowed.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://allowed.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('access-control-allow-origin')).toBe('https://allowed.com') - const { response: response2 } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://disallowed.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response: response2 } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://disallowed.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response2!.headers.get('access-control-allow-origin')).toBeNull() }) it('supports an async timingOrigin function resolved per request', async () => { const plugin = new CORSHandlerPlugin({ - timingOrigin: async origin => origin === 'https://timing.com' ? origin : null, + timingOrigin: async (origin) => (origin === 'https://timing.com' ? origin : null), }) const handler = new RPCHandler(router, { plugins: [plugin], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://timing.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://timing.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('timing-allow-origin')).toBe('https://timing.com') }) it('does not copy Vary from the request', async () => { const handler = new RPCHandler(router, { - plugins: [new CORSHandlerPlugin({ origin: origin => origin })], + plugins: [new CORSHandlerPlugin({ origin: (origin) => origin })], }) - const { response } = await handler.handle(new Request('https://example.com', { - method: 'OPTIONS', - headers: { - origin: 'https://example.com', - vary: 'Accept-Encoding', - }, - })) + const { response } = await handler.handle( + new Request('https://example.com', { + method: 'OPTIONS', + headers: { + origin: 'https://example.com', + vary: 'Accept-Encoding', + }, + }), + ) expect(response!.headers.get('access-control-allow-origin')).toBe('https://example.com') expect(response!.headers.get('vary')).toBe('Origin') @@ -432,23 +492,25 @@ describe('corsHandlerPlugin', () => { }, } }, - ...options.routingInterceptors ?? [], + ...(options.routingInterceptors ?? []), ], } }, }, - new CORSHandlerPlugin({ origin: origin => origin }), + new CORSHandlerPlugin({ origin: (origin) => origin }), ], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://example.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://example.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('vary')).toBe('Accept-Encoding, Origin') }) @@ -478,23 +540,25 @@ describe('corsHandlerPlugin', () => { }, } }, - ...options.routingInterceptors ?? [], + ...(options.routingInterceptors ?? []), ], } }, }, - new CORSHandlerPlugin({ origin: origin => origin }), + new CORSHandlerPlugin({ origin: (origin) => origin }), ], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://example.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://example.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('vary')).toBe('Accept-Encoding, origin') }) @@ -504,14 +568,16 @@ describe('corsHandlerPlugin', () => { plugins: [new CORSHandlerPlugin()], }) - const { matched, response } = await handler.handle(new Request('https://example.com/nonexistent', { - method: 'POST', - headers: { - 'origin': 'https://example.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { matched, response } = await handler.handle( + new Request('https://example.com/nonexistent', { + method: 'POST', + headers: { + 'origin': 'https://example.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(matched).toBe(false) expect(response).toBeUndefined() @@ -526,25 +592,29 @@ describe('corsHandlerPlugin', () => { }) // Allowed origin - const { response } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://a.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://a.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response!.headers.get('access-control-allow-origin')).toBe('https://a.com') // Not allowed origin - const { response: response2 } = await handler.handle(new Request('https://example.com/ping', { - method: 'POST', - headers: { - 'origin': 'https://c.com', - 'content-type': 'application/json', - }, - body: JSON.stringify({ json: null }), - })) + const { response: response2 } = await handler.handle( + new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'origin': 'https://c.com', + 'content-type': 'application/json', + }, + body: JSON.stringify({ json: null }), + }), + ) expect(response2!.headers.get('access-control-allow-origin')).toBeNull() }) }) diff --git a/packages/server/src/plugins/cors.ts b/packages/server/src/plugins/cors.ts index 2bbf28e87c..ebe15c4eaf 100644 --- a/packages/server/src/plugins/cors.ts +++ b/packages/server/src/plugins/cors.ts @@ -1,10 +1,16 @@ import type { Promisable, Value } from '@orpc/shared' -import type { StandardHeaders } from '@standard-server/core' -import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions } from '../adapters/standard' -import type { Context } from '../context' import { toArray } from '@orpc/shared' +import type { StandardHeaders } from '@standard-server/core' import { flattenStandardHeader } from '@standard-server/core' +import type { + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, + StandardHandlerRoutingInterceptorOptions, +} from '../adapters/standard/index.ts' +import type { Context } from '../context.ts' + export interface CORSHandlerPluginOptions { /** * Configures the `Access-Control-Allow-Origin` header. @@ -15,13 +21,23 @@ export interface CORSHandlerPluginOptions { * * @default '*' */ - origin?: Value, [origin: string, options: StandardHandlerRoutingInterceptorOptions]> | undefined + origin?: + | Value< + Promisable, + [origin: string, options: StandardHandlerRoutingInterceptorOptions] + > + | undefined /** * Configures the `Timing-Allow-Origin` header. * Can be a string, an array of allowed origins, or a function (optionally async) that returns the allowed origin(s). */ - timingOrigin?: Value, [origin: string, options: StandardHandlerRoutingInterceptorOptions]> | undefined + timingOrigin?: + | Value< + Promisable, + [origin: string, options: StandardHandlerRoutingInterceptorOptions] + > + | undefined /** * Configures the `Access-Control-Allow-Methods` header for preflight requests. @@ -88,7 +104,9 @@ export class CORSHandlerPlugin implements StandardHandlerPlug } init(options: StandardHandlerOptions): StandardHandlerOptions { - const corsHeadersInterceptor: StandardHandlerRoutingInterceptor = async (interceptorOptions) => { + const corsHeadersInterceptor: StandardHandlerRoutingInterceptor = async ( + interceptorOptions, + ) => { const result = await interceptorOptions.next() if (!result.matched) { @@ -100,7 +118,11 @@ export class CORSHandlerPlugin implements StandardHandlerPlug const origin = flattenStandardHeader(interceptorOptions.request.headers.origin) const allowOrigin = await this.resolveOrigin(this.options.origin, origin, interceptorOptions) - const timingAllowOrigin = await this.resolveOrigin(this.options.timingOrigin, origin, interceptorOptions) + const timingAllowOrigin = await this.resolveOrigin( + this.options.timingOrigin, + origin, + interceptorOptions, + ) if (allowOrigin.value !== undefined) { resHeaders['access-control-allow-origin'] = allowOrigin.value @@ -112,7 +134,7 @@ export class CORSHandlerPlugin implements StandardHandlerPlug if (allowOrigin.varies || timingAllowOrigin.varies) { const existingVary = flattenStandardHeader(resHeaders.vary) - if (!existingVary?.split(',').some(v => v.trim().toLowerCase() === 'origin')) { + if (!existingVary?.split(',').some((v) => v.trim().toLowerCase() === 'origin')) { resHeaders.vary = existingVary ? `${existingVary}, Origin` : 'Origin' } } @@ -122,13 +144,17 @@ export class CORSHandlerPlugin implements StandardHandlerPlug } if (this.options.exposeHeaders?.length) { - resHeaders['access-control-expose-headers'] = flattenStandardHeader(this.options.exposeHeaders) + resHeaders['access-control-expose-headers'] = flattenStandardHeader( + this.options.exposeHeaders, + ) } return { ...result, response: { ...result.response, headers: resHeaders } } } - const preflightInterceptor: StandardHandlerRoutingInterceptor = async (interceptorOptions) => { + const preflightInterceptor: StandardHandlerRoutingInterceptor = async ( + interceptorOptions, + ) => { if (interceptorOptions.request.method === 'OPTIONS') { const resHeaders: StandardHeaders = {} @@ -137,10 +163,14 @@ export class CORSHandlerPlugin implements StandardHandlerPlug } if (this.options.allowMethods?.length) { - resHeaders['access-control-allow-methods'] = flattenStandardHeader(this.options.allowMethods) + resHeaders['access-control-allow-methods'] = flattenStandardHeader( + this.options.allowMethods, + ) } - const allowHeaders = this.options.allowHeaders ?? interceptorOptions.request.headers['access-control-request-headers'] + const allowHeaders = + this.options.allowHeaders ?? + interceptorOptions.request.headers['access-control-request-headers'] if (typeof allowHeaders === 'string' || allowHeaders?.length) { resHeaders['access-control-allow-headers'] = flattenStandardHeader(allowHeaders) @@ -173,7 +203,7 @@ export class CORSHandlerPlugin implements StandardHandlerPlug option: CORSHandlerPluginOptions['origin'], origin: string | undefined, interceptorOptions: StandardHandlerRoutingInterceptorOptions, - ): Promise<{ value: string | undefined, varies: boolean }> { + ): Promise<{ value: string | undefined; varies: boolean }> { if (typeof option === 'function') { if (origin === undefined) { return { value: undefined, varies: true } diff --git a/packages/server/src/plugins/get-method-csrf-protection.test.ts b/packages/server/src/plugins/get-method-csrf-protection.test.ts index d43e1013e5..841db7f9bb 100644 --- a/packages/server/src/plugins/get-method-csrf-protection.test.ts +++ b/packages/server/src/plugins/get-method-csrf-protection.test.ts @@ -1,9 +1,10 @@ import { ORPCError } from '@orpc/client' -import { RPCHandler } from '../adapters/fetch/rpc-handler' -import { RPC_DEFAULT_ALLOW_METHODS } from '../adapters/standard' -import { os } from '../builder' -import { BatchHandlerPlugin } from './batch' -import { GetMethodCsrfProtectionHandlerPlugin } from './get-method-csrf-protection' + +import { RPCHandler } from '../adapters/fetch/rpc-handler.ts' +import { RPC_DEFAULT_ALLOW_METHODS } from '../adapters/standard/index.ts' +import { os } from '../builder.ts' +import { BatchHandlerPlugin } from './batch.ts' +import { GetMethodCsrfProtectionHandlerPlugin } from './get-method-csrf-protection.ts' function makeRequest(headers: Record, method: string) { return { @@ -33,7 +34,8 @@ function invokeInterceptor(headers: Record, method = 'GET') { const next = vi.fn().mockResolvedValue(nextResult) const { interceptor } = getPlugin() - const result = (async () => interceptor({ context: {}, request: makeRequest(headers, method), next } as any))() + const result = (async () => + interceptor({ context: {}, request: makeRequest(headers, method), next } as any))() return { result, next, nextResult } } @@ -48,10 +50,12 @@ async function expectAllowed(headers: Record, method = 'GET') { async function expectBlocked(headers: Record, method = 'GET') { const { result, next } = invokeInterceptor(headers, method) - await expect(result).rejects.toSatisfy(error => - error instanceof ORPCError - && error.code === 'FORBIDDEN' - && error.message === 'Request blocked by CSRF protection.') + await expect(result).rejects.toSatisfy( + (error) => + error instanceof ORPCError && + error.code === 'FORBIDDEN' && + error.message === 'Request blocked by CSRF protection.', + ) expect(next).not.toHaveBeenCalled() } @@ -77,13 +81,12 @@ describe('getMethodCsrfProtectionHandlerPlugin', () => { }) describe('guarded methods', () => { - it.each([ - 'POST', - 'HEAD', - 'QUERY', - ])('ignores %s requests, which never carry cross-site SameSite=Lax cookies', async (method) => { - await expectAllowed(CROSS_SITE_NAVIGATION, method) - }) + it.each(['POST', 'HEAD', 'QUERY'])( + 'ignores %s requests, which never carry cross-site SameSite=Lax cookies', + async (method) => { + await expectAllowed(CROSS_SITE_NAVIGATION, method) + }, + ) it('guards GET requests', async () => { await expectBlocked(CROSS_SITE_NAVIGATION, 'GET') @@ -101,35 +104,53 @@ describe('getMethodCsrfProtectionHandlerPlugin', () => { }) describe('fetch site', () => { - it.each([ - 'same-origin', - 'SAME-ORIGIN', - 'same-site', - ])('trusts even a navigation when sec-fetch-site is %s', async (site) => { - await expectAllowed({ ...CROSS_SITE_NAVIGATION, 'sec-fetch-site': site }) - }) + it.each(['same-origin', 'SAME-ORIGIN', 'same-site'])( + 'trusts even a navigation when sec-fetch-site is %s', + async (site) => { + await expectAllowed({ ...CROSS_SITE_NAVIGATION, 'sec-fetch-site': site }) + }, + ) it('judges an unrecognized sec-fetch-site value like a cross-site one', async () => { await expectBlocked({ ...CROSS_SITE_NAVIGATION, 'sec-fetch-site': 'future-value' }) }) it('judges a repeated sec-fetch-site header like a cross-site one', async () => { - await expectBlocked({ ...CROSS_SITE_NAVIGATION, 'sec-fetch-site': ['same-site', 'same-site'] }) + await expectBlocked({ + ...CROSS_SITE_NAVIGATION, + 'sec-fetch-site': ['same-site', 'same-site'], + }) }) }) describe('cross-site and browser-initiated navigations', () => { it.each([ ['a link click or redirect', CROSS_SITE_NAVIGATION], - ['a link click matching header casing loosely', { 'sec-fetch-site': 'Cross-Site', 'sec-fetch-mode': 'Navigate', 'sec-fetch-dest': 'Document' }], - ['an address-bar, bookmark, email, or native-app navigation', { 'sec-fetch-site': 'none', 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }], + [ + 'a link click matching header casing loosely', + { + 'sec-fetch-site': 'Cross-Site', + 'sec-fetch-mode': 'Navigate', + 'sec-fetch-dest': 'Document', + }, + ], + [ + 'an address-bar, bookmark, email, or native-app navigation', + { 'sec-fetch-site': 'none', 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }, + ], ])('blocks %s', async (_, headers) => { await expectBlocked(headers) }) it.each([ - ['sec-fetch-mode is stripped', { 'sec-fetch-site': 'cross-site', 'sec-fetch-dest': 'document' }], - ['sec-fetch-dest is stripped', { 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'navigate' }], + [ + 'sec-fetch-mode is stripped', + { 'sec-fetch-site': 'cross-site', 'sec-fetch-dest': 'document' }, + ], + [ + 'sec-fetch-dest is stripped', + { 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'navigate' }, + ], ['both are stripped', { 'sec-fetch-site': 'cross-site' }], ])('treats a stripped header as its dangerous value when %s', async (_, headers) => { await expectBlocked(headers) @@ -138,11 +159,34 @@ describe('getMethodCsrfProtectionHandlerPlugin', () => { describe('cross-site requests browsers send without SameSite=Lax cookies', () => { it.each([ - ['a fetch or XMLHttpRequest', { 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' }], - ['an ,