diff --git a/apps/content/content/docs/index.mdx b/apps/content/content/docs/index.mdx index 87b5ef830..6668df92c 100644 --- a/apps/content/content/docs/index.mdx +++ b/apps/content/content/docs/index.mdx @@ -144,25 +144,26 @@ In oRPC middleware is very useful and fully typed you can find more info [here]( This example uses [@whatwg-node/server](https://www.npmjs.com/package/@whatwg-node/server) to create a Node server with [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). ```ts twoslash -import { createFetchHandler } from '@orpc/server/fetch' +import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' +import { createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { createServer } from 'node:http' import { createServerAdapter } from '@whatwg-node/server' import { router } from 'examples/server' -const handler = createFetchHandler({ - router, - serverless: false, // set true will improve cold start times -}) - const server = createServer( createServerAdapter((request: Request) => { const url = new URL(request.url) if (url.pathname.startsWith('/api')) { - return handler({ + return handleFetchRequest({ + router, request, prefix: '/api', context: {}, + handlers: [ + createORPCHandler(), + createOpenAPIServerlessHandler(), + ], }) } diff --git a/apps/content/content/docs/server/context.mdx b/apps/content/content/docs/server/context.mdx index e2a15ab10..bb468f613 100644 --- a/apps/content/content/docs/server/context.mdx +++ b/apps/content/content/docs/server/context.mdx @@ -46,7 +46,6 @@ If your procedure only depends on `Middleware Context`, you can ```ts twoslash import { os, ORPCError } from '@orpc/server' -import { createFetchHandler } from '@orpc/server/fetch' import { headers } from 'next/headers' const base = os.use(async (input, context, meta) => { @@ -83,14 +82,18 @@ export const router = base.router({ // You can call this procedure directly without manually providing context const output = await router.getting() -const handler = createFetchHandler({ - router, -}) +import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' +import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' export function fetch(request: Request) { // No need to pass context; middleware handles it - return handler({ + return handleFetchRequest({ + router, request, + handlers: [ + createORPCHandler(), + createOpenAPIServerlessHandler() + ], }) } ``` @@ -103,7 +106,8 @@ rather than relying on global mechanisms like `headers` or `cookies` in Next.js. ```ts twoslash import { os, ORPCError, createProcedureCaller } from '@orpc/server' -import { createFetchHandler } from '@orpc/server/fetch' +import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' +import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' type ORPCContext = { user?: { id: string }, db: 'fake-db' } @@ -129,18 +133,16 @@ export const router = base.router({ }), }) -const handler = createFetchHandler({ - router, -}) - export function fetch(request: Request) { // Initialize context explicitly for each request const db = 'fake-db' as const const user = request.headers.get('Authorization') ? { id: 'example' } : undefined - return handler({ + return handleFetchRequest({ + router, request, context: { db, user }, + handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], }) } diff --git a/apps/content/content/docs/server/integrations.mdx b/apps/content/content/docs/server/integrations.mdx index 6469394fd..f27e71109 100644 --- a/apps/content/content/docs/server/integrations.mdx +++ b/apps/content/content/docs/server/integrations.mdx @@ -13,19 +13,20 @@ Whether you're targeting serverless, edge environments, or traditional backends, ## Quick Example ```ts twoslash -import { createFetchHandler } from '@orpc/server/fetch' +import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' +import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' -const handler = createFetchHandler({ - router, - serverless: false, // Set true for faster cold starts -}) - export function fetch(request: Request) { - return handler({ + return handleFetchRequest({ + router, request, context: {}, // prefix: '/api', // Optionally define a route prefix + handlers: [ + createORPCHandler(), + createOpenAPIServerlessHandler(), + ], }) } ``` @@ -36,22 +37,23 @@ Node.js doesn't provide native support for creating server with [Fetch API](http but you can easily use [@whatwg-node/server](https://npmjs.com/package/@whatwg-node/server) as an adapter. ```ts twoslash -import { createFetchHandler } from '@orpc/server/fetch' +import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' +import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' import { createServer } from 'node:http' import { createServerAdapter } from '@whatwg-node/server' import { router } from 'examples/server' -const handler = createFetchHandler({ - router, - serverless: false, -}) - const server = createServer( createServerAdapter((request: Request) => { - return handler({ + return handleFetchRequest({ + router, request, context: {}, // prefix: '/api', + handlers: [ + createORPCHandler(), + createOpenAPIServerlessHandler(), + ], }) }) ) @@ -64,23 +66,24 @@ server.listen(3000, () => { ## Express.js ```ts twoslash -import { createFetchHandler } from '@orpc/server/fetch' +import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' +import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' import { createServerAdapter } from '@whatwg-node/server' import express from 'express' import { router } from 'examples/server' -const handler = createFetchHandler({ - router, - serverless: false, -}) - const app = express() app.all('/api/*', createServerAdapter((request: Request) => { - return handler({ + return handleFetchRequest({ + router, request, context: {}, prefix: '/api', + handlers: [ + createORPCHandler(), + createOpenAPIServerHandler(), + ], }) })) @@ -93,21 +96,22 @@ app.listen(3000, () => { ```ts twoslash import { Hono } from 'hono' -import { createFetchHandler } from '@orpc/server/fetch' +import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' +import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' -const handler = createFetchHandler({ - router, - serverless: false, -}) - const app = new Hono() app.get('/api/*', (c) => { - return handler({ + return handleFetchRequest({ + router, request: c.req.raw, prefix: '/api', context: {}, + handlers: [ + createORPCHandler(), + createOpenAPIServerlessHandler(), + ], }) }) @@ -117,46 +121,47 @@ export default app ## Next.js ```ts title="app/api/[...orpc]/route.ts" twoslash -import { createFetchHandler } from '@orpc/server/fetch' +import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' +import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' -const handler = createFetchHandler({ - router, - serverless: true, -}) - -const fetchRequestHandler = async (request: Request) => { - return handler({ +export function GET(request: Request) { + return handleFetchRequest({ + router, request, prefix: '/api', context: {}, + handlers: [ + createORPCHandler(), + createOpenAPIServerlessHandler(), + ], }) } -export const GET = fetchRequestHandler -export const POST = fetchRequestHandler -export const PUT = fetchRequestHandler -export const DELETE = fetchRequestHandler -export const PATCH = fetchRequestHandler +export const POST = GET +export const PUT = GET +export const DELETE = GET +export const PATCH = GET ``` ## Cloudflare Workers ```ts twoslash -import { createFetchHandler } from '@orpc/server/fetch' +import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' +import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' -const handler = createFetchHandler({ - router, - serverless: true, -}) - export default { async fetch(request: Request) { - return handler({ + return handleFetchRequest({ + router, request, prefix: '/', context: {}, + handlers: [ + createORPCHandler(), + createOpenAPIServerlessHandler(), + ], }) }, } diff --git a/apps/content/examples/contract.ts b/apps/content/examples/contract.ts index 8f71238a9..ccd0afd9f 100644 --- a/apps/content/examples/contract.ts +++ b/apps/content/examples/contract.ts @@ -7,15 +7,6 @@ import { z } from 'zod' import { ORPCError, os } from '@orpc/server' -// Expose apis to the internet with fetch handler - -import { createFetchHandler } from '@orpc/server/fetch' - -// Modern runtime that support fetch api like deno, bun, cloudflare workers, even node can used - -import { createServer } from 'node:http' -import { createServerAdapter } from '@whatwg-node/server' - // Define your contract first // This contract can replace server router in most-case @@ -123,20 +114,27 @@ export const router = pub.router({ }, }) -const handler = createFetchHandler({ - router, - serverless: false, // set true will improve cold start times -}) +// Expose apis to the internet with fetch handler +import { createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +// Modern runtime that support fetch api like deno, bun, cloudflare workers, even node can used +import { createServer } from 'node:http' +import { createServerAdapter } from '@whatwg-node/server' const server = createServer( createServerAdapter((request: Request) => { const url = new URL(request.url) if (url.pathname.startsWith('/api')) { - return handler({ + return handleFetchRequest({ + router, request, prefix: '/api', context: {}, + handlers: [ + createORPCHandler(), + createOpenAPIServerlessHandler(), + ], }) } diff --git a/apps/content/examples/server.ts b/apps/content/examples/server.ts index 5514e872b..c7a7be7ec 100644 --- a/apps/content/examples/server.ts +++ b/apps/content/examples/server.ts @@ -3,15 +3,6 @@ import { ORPCError, os } from '@orpc/server' import { oz } from '@orpc/zod' import { z } from 'zod' -// Expose apis to the internet with fetch handler - -import { createFetchHandler } from '@orpc/server/fetch' - -// Modern runtime that support fetch api like deno, bun, cloudflare workers, even node can used - -import { createServer } from 'node:http' -import { createServerAdapter } from '@whatwg-node/server' - export type Context = { user?: { id: string } } // global pub, authed completely optional @@ -97,20 +88,27 @@ export const router = pub.router({ export type Inputs = InferRouterInputs export type Outputs = InferRouterOutputs -const handler = createFetchHandler({ - router, - serverless: false, // set true will improve cold start times -}) +// Expose apis to the internet with fetch handler +import { createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +// Modern runtime that support fetch api like deno, bun, cloudflare workers, even node can used +import { createServer } from 'node:http' +import { createServerAdapter } from '@whatwg-node/server' const server = createServer( createServerAdapter((request: Request) => { const url = new URL(request.url) if (url.pathname.startsWith('/api')) { - return handler({ + return handleFetchRequest({ + router, request, prefix: '/api', context: {}, + handlers: [ + createORPCHandler(), + createOpenAPIServerlessHandler(), + ], }) } diff --git a/eslint.config.js b/eslint.config.js index fcde1927e..424b25363 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,11 @@ export default antfu({ 'unused-imports/no-unused-vars': 'off', 'antfu/no-top-level-await': 'off', }, +}, { + files: ['apps/content/examples/**'], + rules: { + 'import/first': 'off', + }, }, { files: ['playgrounds/**'], rules: { diff --git a/packages/client/package.json b/packages/client/package.json index ec28e1175..c015d87b3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -49,6 +49,7 @@ "@orpc/transformer": "workspace:*" }, "devDependencies": { + "@orpc/openapi": "workspace:*", "zod": "^3.23.8" } } diff --git a/packages/client/src/procedure.test.ts b/packages/client/src/procedure.test.ts index 953339210..3ef81088a 100644 --- a/packages/client/src/procedure.test.ts +++ b/packages/client/src/procedure.test.ts @@ -1,5 +1,6 @@ +import { createOpenAPIServerHandler, createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { ORPCError, os } from '@orpc/server' -import { createFetchHandler } from '@orpc/server/fetch' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { z } from 'zod' import { createProcedureClient } from './procedure' @@ -14,12 +15,12 @@ describe('createProcedureClient', () => { ping, }, }) - const handler = createFetchHandler({ - router, - }) + const orpcFetch: typeof fetch = async (...args) => { const request = new Request(...args) - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createOpenAPIServerHandler(), createOpenAPIServerlessHandler(), createORPCHandler()], // make sure still work with openapi handlers prefix: '/orpc', request, context: {}, @@ -114,16 +115,14 @@ describe('createProcedureClient', () => { .func(input => input.value), }) - const handler = createFetchHandler({ - router, - }) - const client = createProcedureClient({ path: ['ping'], baseURL: 'http://localhost:3000/orpc', fetch: (...args) => { const request = new Request(...args) - return handler({ + return handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], prefix: '/orpc', request, context: {}, @@ -147,16 +146,14 @@ describe('createProcedureClient', () => { }), }) - const handler = createFetchHandler({ - router, - }) - const client = createProcedureClient({ path: ['ping'], baseURL: 'http://localhost:3000/orpc', fetch: (...args) => { const request = new Request(...args) - return handler({ + return handleFetchRequest({ + router, + handlers: [createORPCHandler()], prefix: '/orpc', request, context: {}, @@ -192,16 +189,15 @@ describe('upload file', () => { }), }) - const handler = createFetchHandler({ router }) - const orpcFetch: typeof fetch = async (...args) => { const request = new Request(...args) - const response = await handler({ + return handleFetchRequest({ + router, + handlers: [createORPCHandler()], prefix: '/orpc', request, context: {}, }) - return response } const blob1 = new Blob(['hello'], { type: 'text/plain;charset=utf-8' }) diff --git a/packages/client/src/router.test.ts b/packages/client/src/router.test.ts index e17f1a093..8a04761a2 100644 --- a/packages/client/src/router.test.ts +++ b/packages/client/src/router.test.ts @@ -1,6 +1,6 @@ import { oc } from '@orpc/contract' import { os } from '@orpc/server' -import { createFetchHandler } from '@orpc/server/fetch' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { z } from 'zod' import { createRouterClient } from './router' @@ -15,15 +15,14 @@ describe('createRouterClient', () => { unique: ping, }, }) - const handler = createFetchHandler({ - router, - }) const orpcFetch: typeof fetch = async (...args) => { const request = new Request(...args) - return await handler({ + return await handleFetchRequest({ + router, prefix: '/orpc', request, context: {}, + handlers: [createORPCHandler()], }) } @@ -126,18 +125,16 @@ describe('createRouterClient', () => { .func(input => input.value), }) - const handler = createFetchHandler({ - router, - }) - const client = createRouterClient({ baseURL: 'http://localhost:3000/orpc', fetch: (...args) => { const request = new Request(...args) - return handler({ + return handleFetchRequest({ + router, prefix: '/orpc', request, context: {}, + handlers: [createORPCHandler()], }) }, }) diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 0b79da693..43168db65 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -5,6 +5,7 @@ }, "references": [ { "path": "../contract" }, + { "path": "../openapi" }, { "path": "../server" }, { "path": "../shared" }, { "path": "../transformer" } diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 208061886..337188604 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -20,6 +20,11 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./fetch": { + "types": "./dist/src/fetch/index.d.ts", + "import": "./dist/fetch.js", + "default": "./dist/fetch.js" + }, "./🔒/*": { "types": "./dist/src/*.d.ts" } @@ -27,6 +32,7 @@ }, "exports": { ".": "./src/index.ts", + "./fetch": "./src/fetch/index.ts", "./🔒/*": { "types": "./src/*.ts" } @@ -36,7 +42,7 @@ "dist" ], "scripts": { - "build": "tsup --clean --entry.index=src/index.ts --format=esm --onSuccess='tsc -b --noCheck'", + "build": "tsup --clean --entry.index=src/index.ts --entry.fetch=src/fetch/index.ts --format=esm --onSuccess='tsc -b --noCheck'", "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, @@ -52,6 +58,7 @@ }, "devDependencies": { "@readme/openapi-parser": "^2.6.0", + "hono": "^4.6.12", "zod": "^3.23.8" } } diff --git a/packages/openapi/src/fetch/base-handler.ts b/packages/openapi/src/fetch/base-handler.ts new file mode 100644 index 000000000..2b600077a --- /dev/null +++ b/packages/openapi/src/fetch/base-handler.ts @@ -0,0 +1,212 @@ +/// + +import type { HTTPPath } from '@orpc/contract' +import type { FetchHandler } from '@orpc/server/fetch' +import type { Router as HonoRouter } from 'hono/router' +import { ORPC_HEADER, standardizeHTTPPath } from '@orpc/contract' +import { createProcedureCaller, isProcedure, ORPCError, type Procedure, type Router, type WELL_DEFINED_PROCEDURE } from '@orpc/server' +import { isPlainObject, mapValues, trim, value } from '@orpc/shared' +import { OpenAPIDeserializer, OpenAPISerializer, zodCoerce } from '@orpc/transformer' + +export type ResolveRouter = (router: Router, method: string, pathname: string) => { + path: string[] + procedure: Procedure + params: Record +} | undefined + +type Routing = HonoRouter<[string[], Procedure]> + +export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHandler { + const resolveRouter = createResolveRouter(createHonoRouter) + + return async (options) => { + if (options.request.headers.get(ORPC_HEADER) !== null) { + return undefined + } + + const context = await value(options.context) + const accept = options.request.headers.get('Accept') || undefined + const serializer = new OpenAPISerializer({ accept }) + + const handler = async () => { + const url = new URL(options.request.url) + const pathname = `/${trim(url.pathname.replace(options.prefix ?? '', ''), '/')}` + const customMethod + = options.request.method === 'POST' + ? url.searchParams.get('method')?.toUpperCase() + : undefined + const method = customMethod || options.request.method + + const match = resolveRouter(options.router, method, pathname) + + if (!match) { + throw new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) + } + const procedure = match.procedure + const path = match.path + + const params = procedure.zz$p.contract.zz$cp.InputSchema + ? zodCoerce( + procedure.zz$p.contract.zz$cp.InputSchema, + match.params, + { bracketNotation: true }, + ) as Record + : match.params + + const input = await deserializeInput(options.request, procedure) + const mergedInput = mergeParamsAndInput(params, input) + + const caller = createProcedureCaller({ + context, + procedure, + path, + }) + + const output = await caller(mergedInput) + + const { body, headers } = serializer.serialize(output) + + return new Response(body, { + status: 200, + headers, + }) + } + + try { + return await options.hooks?.(context as any, { + next: handler, + response: response => response, + }) ?? await handler() + } + catch (e) { + const error = toORPCError(e) + + try { + const { body, headers } = serializer.serialize(error.toJSON()) + + return new Response(body, { + status: error.status, + headers, + }) + } + catch (e) { + const error = toORPCError(e) + + // fallback to OpenAPI serializer (without accept) when expected serializer has failed + const { body, headers } = new OpenAPISerializer().serialize( + error.toJSON(), + ) + + return new Response(body, { + status: error.status, + headers, + }) + } + } + } +} + +const routingCache = new Map, Routing>() + +export function createResolveRouter(createHonoRouter: () => Routing): ResolveRouter { + return (router: Router, method: string, pathname: string) => { + let routing = routingCache.get(router) + + if (!routing) { + routing = createHonoRouter() + + const addRouteRecursively = (routing: Routing, router: Router, basePath: string[]) => { + for (const key in router) { + const currentPath = [...basePath, key] + const item = router[key] as WELL_DEFINED_PROCEDURE | Router + + if (isProcedure(item)) { + const method = item.zz$p.contract.zz$cp.method ?? 'POST' + const path = item.zz$p.contract.zz$cp.path + ? openAPIPathToRouterPath(item.zz$p.contract.zz$cp.path) + : `/${currentPath.map(encodeURIComponent).join('/')}` + + routing.add(method, path, [currentPath, item]) + } + else { + addRouteRecursively(routing, item, currentPath) + } + } + } + + addRouteRecursively(routing, router, []) + routingCache.set(router, routing) + } + + const [matches, params_] = routing.match(method, pathname) + + const [match] = matches.sort((a, b) => { + return Object.keys(a[1]).length - Object.keys(b[1]).length + }) + + if (!match) { + return undefined + } + + const path = match[0][0] + const procedure = match[0][1] + const params = params_ + ? mapValues( + (match as any)[1]!, + v => params_[v as number]!, + ) + : match[1] as Record + + return { + path, + procedure, + params: { ...params }, // params from hono not a normal object, so we need spread here + } + } +} + +function mergeParamsAndInput(coercedParams: Record, input: unknown) { + if (Object.keys(coercedParams).length === 0) { + return input + } + + if (!isPlainObject(input)) { + return coercedParams + } + + return { + ...coercedParams, + ...input, + } +} + +async function deserializeInput(request: Request, procedure: Procedure): Promise { + const deserializer = new OpenAPIDeserializer({ + schema: procedure.zz$p.contract.zz$cp.InputSchema, + }) + + try { + return await deserializer.deserialize(request) + } + catch (e) { + throw new ORPCError({ + code: 'BAD_REQUEST', + message: 'Cannot parse request. Please check the request body and Content-Type header.', + cause: e, + }) + } +} + +function toORPCError(e: unknown): ORPCError { + return e instanceof ORPCError + ? e + : new ORPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error', + cause: e, + }) +} + +export function openAPIPathToRouterPath(path: HTTPPath): string { + return standardizeHTTPPath(path).replace(/\{([^}]+)\}/g, ':$1') +} diff --git a/packages/openapi/src/fetch/index.ts b/packages/openapi/src/fetch/index.ts new file mode 100644 index 000000000..18c20ca54 --- /dev/null +++ b/packages/openapi/src/fetch/index.ts @@ -0,0 +1,3 @@ +export * from './base-handler' +export * from './server-handler' +export * from './serverless-handler' diff --git a/packages/openapi/src/fetch/server-handler.ts b/packages/openapi/src/fetch/server-handler.ts new file mode 100644 index 000000000..687688d80 --- /dev/null +++ b/packages/openapi/src/fetch/server-handler.ts @@ -0,0 +1,7 @@ +import type { FetchHandler } from '@orpc/server/fetch' +import { RegExpRouter } from 'hono/router/reg-exp-router' +import { createOpenAPIHandler } from './base-handler' + +export function createOpenAPIServerHandler(): FetchHandler { + return createOpenAPIHandler(() => new RegExpRouter()) +} diff --git a/packages/openapi/src/fetch/serverless-handler.ts b/packages/openapi/src/fetch/serverless-handler.ts new file mode 100644 index 000000000..784c45a94 --- /dev/null +++ b/packages/openapi/src/fetch/serverless-handler.ts @@ -0,0 +1,7 @@ +import type { FetchHandler } from '@orpc/server/fetch' +import { LinearRouter } from 'hono/router/linear-router' +import { createOpenAPIHandler } from './base-handler' + +export function createOpenAPIServerlessHandler(): FetchHandler { + return createOpenAPIHandler(() => new LinearRouter()) +} diff --git a/packages/react/tests/orpc.tsx b/packages/react/tests/orpc.tsx index 57c20cd44..3dc6cdb6a 100644 --- a/packages/react/tests/orpc.tsx +++ b/packages/react/tests/orpc.tsx @@ -1,6 +1,6 @@ import { createORPCClient } from '@orpc/client' import { os } from '@orpc/server' -import { createFetchHandler } from '@orpc/server/fetch' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React, { Suspense } from 'react' import { z } from 'zod' @@ -92,8 +92,6 @@ export const appRouter = orpcServer.router({ }, }) -export const appHandler = createFetchHandler({ router: appRouter }) - export const orpcClient = createORPCClient({ baseURL: 'http://localhost:3000', @@ -101,9 +99,10 @@ export const orpcClient = createORPCClient({ await new Promise(resolve => setTimeout(resolve, 100)) const request = new Request(...args) - return appHandler({ + return handleFetchRequest({ + router: appRouter, request, - context: undefined, + handlers: [createORPCHandler()], }) }, }) diff --git a/packages/server/package.json b/packages/server/package.json index a3a021ba7..6091160bc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -21,7 +21,7 @@ "default": "./dist/index.js" }, "./fetch": { - "types": "./dist/src/adapters/fetch.d.ts", + "types": "./dist/src/fetch/index.d.ts", "import": "./dist/fetch.js", "default": "./dist/fetch.js" }, @@ -32,7 +32,7 @@ }, "exports": { ".": "./src/index.ts", - "./fetch": "./src/adapters/fetch.ts", + "./fetch": "./src/fetch/index.ts", "./🔒/*": { "types": "./src/*.ts" } @@ -42,7 +42,7 @@ "dist" ], "scripts": { - "build": "tsup --clean --entry.index=src/index.ts --entry.fetch=src/adapters/fetch.ts --format=esm --onSuccess='tsc -b --noCheck'", + "build": "tsup --clean --entry.index=src/index.ts --entry.fetch=src/fetch/index.ts --format=esm --onSuccess='tsc -b --noCheck'", "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, @@ -56,6 +56,6 @@ "@orpc/transformer": "workspace:*" }, "devDependencies": { - "hono": "^4.6.3" + "@orpc/openapi": "workspace:*" } } diff --git a/packages/server/src/adapters/fetch.ts b/packages/server/src/adapters/fetch.ts deleted file mode 100644 index ce1e66958..000000000 --- a/packages/server/src/adapters/fetch.ts +++ /dev/null @@ -1,290 +0,0 @@ -/// - -import type { - PartialOnUndefinedDeep, - Promisable, - Value, -} from '@orpc/shared' -import type { Router } from '../router' -import { - type HTTPPath, - ORPC_HEADER, - ORPC_HEADER_VALUE, - standardizeHTTPPath, -} from '@orpc/contract' -import { - get, - isPlainObject, - mapValues, - trim, - value, -} from '@orpc/shared' -import { ORPCError } from '@orpc/shared/error' -import { - OpenAPIDeserializer, - OpenAPISerializer, - ORPCDeserializer, - ORPCSerializer, - zodCoerce, -} from '@orpc/transformer' -import { LinearRouter } from 'hono/router/linear-router' -import { RegExpRouter } from 'hono/router/reg-exp-router' -import { isProcedure, type WELL_DEFINED_PROCEDURE } from '../procedure' -import { createProcedureCaller } from '../procedure-caller' - -export interface FetchHandlerHooks { - next: () => Promise - response: (response: Response) => Response -} - -export interface CreateFetchHandlerOptions> { - router: TRouter - - /** - * Hooks for executing logics on lifecycle events. - */ - hooks?: ( - context: TRouter extends Router ? UContext : never, - hooks: FetchHandlerHooks, - ) => Promisable - - /** - * It will help improve the cold start time. But it will increase the performance. - * - * @default false - */ - serverless?: boolean -} - -export function createFetchHandler>( - options: CreateFetchHandlerOptions, -): FetchHandler { - const routing = options.serverless - ? new LinearRouter<[string[], WELL_DEFINED_PROCEDURE]>() - : new RegExpRouter<[string[], WELL_DEFINED_PROCEDURE]>() - - const addRouteRecursively = (router: Router, basePath: string[]) => { - for (const key in router) { - const currentPath = [...basePath, key] - const item = router[key] as WELL_DEFINED_PROCEDURE | Router - - if (isProcedure(item)) { - if (item.zz$p.contract.zz$cp.path) { - const method = item.zz$p.contract.zz$cp.method ?? 'POST' - const path = openAPIPathToRouterPath(item.zz$p.contract.zz$cp.path) - - routing.add(method, path, [currentPath, item]) - } - } - else { - addRouteRecursively(item, currentPath) - } - } - } - - addRouteRecursively(options.router, []) - - return async (requestOptions) => { - const isORPCTransformer - = requestOptions.request.headers.get(ORPC_HEADER) === ORPC_HEADER_VALUE - const accept = requestOptions.request.headers.get('Accept') || undefined - - const serializer = isORPCTransformer - ? new ORPCSerializer() - : new OpenAPISerializer({ accept }) - - const context = await value(requestOptions.context) - - const handler = async () => { - const url = new URL(requestOptions.request.url) - const pathname = `/${trim(url.pathname.replace(requestOptions.prefix ?? '', ''), '/')}` - - let path: string[] | undefined - let procedure: WELL_DEFINED_PROCEDURE | undefined - let params: Record | undefined - - if (isORPCTransformer) { - path = trim(pathname, '/').split('/').map(decodeURIComponent) - const val = get(options.router, path) - - if (isProcedure(val)) { - procedure = val - } - } - else { - const customMethod - = requestOptions.request.method === 'POST' - ? url.searchParams.get('method')?.toUpperCase() - : undefined - const method = customMethod || requestOptions.request.method - - const [matches, params_] = routing.match(method, pathname) - - const [match] = matches.sort((a, b) => { - return Object.keys(a[1]).length - Object.keys(b[1]).length - }) - - if (match) { - path = match[0][0] - procedure = match[0][1] - - if (params_) { - params = mapValues( - (match as any)[1]!, - v => params_[v as number]!, - ) - } - else { - params = match[1] as Record - } - } - - if (!path || !procedure) { - path = trim(pathname, '/').split('/').map(decodeURIComponent) - - const val = get(options.router, path) - - if (isProcedure(val)) { - procedure = val - } - } - } - - if (!path || !procedure) { - throw new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) - } - - const deserializer = isORPCTransformer - ? new ORPCDeserializer() - : new OpenAPIDeserializer({ - schema: procedure.zz$p.contract.zz$cp.InputSchema, - }) - - const input_ = await (async () => { - try { - return await deserializer.deserialize(requestOptions.request) - } - catch (e) { - throw new ORPCError({ - code: 'BAD_REQUEST', - message: - 'Cannot parse request. Please check the request body and Content-Type header.', - cause: e, - }) - } - })() - - const input = (() => { - if (!params || Object.keys(params).length === 0) { - return input_ - } - - const coercedParams = procedure.zz$p.contract.zz$cp.InputSchema - ? (zodCoerce( - procedure.zz$p.contract.zz$cp.InputSchema, - { ...params }, - { - bracketNotation: true, - }, - ) as object) - : params - - if (!isPlainObject(input_)) { - return coercedParams - } - - return { - ...coercedParams, - ...input_, - } - })() - - const caller = createProcedureCaller({ - context, - procedure, - path, - }) - - const output = await caller(input) - - const { body, headers } = serializer.serialize(output) - - return new Response(body, { - status: 200, - headers, - }) - } - - try { - return await options.hooks?.(context as any, { - next: handler, - response: response => response, - }) ?? await handler() - } - catch (e) { - const error = toORPCError(e) - - try { - const { body, headers } = serializer.serialize(error.toJSON()) - - return new Response(body, { - status: error.status, - headers, - }) - } - catch (e) { - const error = toORPCError(e) - - // fallback to OpenAPI serializer (without accept) when expected serializer has failed - const { body, headers } = new OpenAPISerializer().serialize( - error.toJSON(), - ) - - return new Response(body, { - status: error.status, - headers, - }) - } - } - } -} - -function openAPIPathToRouterPath(path: HTTPPath): string { - return standardizeHTTPPath(path).replace(/\{([^}]+)\}/g, ':$1') -} - -export type FetchHandlerOptions> = { - /** - * The request need to be handled. - */ - request: Request - - /** - * Remove the prefix from the request path. - * - * @example /orpc - * @example /api - */ - prefix?: string -} & PartialOnUndefinedDeep<{ - /** - * The context used to handle the request. - */ - context: Value< - TRouter extends Router ? UContext : never - > -}> - -export interface FetchHandler> { - (options: FetchHandlerOptions): Promise -} - -function toORPCError(e: unknown): ORPCError { - return e instanceof ORPCError - ? e - : new ORPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Internal server error', - cause: e, - }) -} diff --git a/packages/server/src/adapters/fetch.test.ts b/packages/server/src/fetch/handle.test.ts similarity index 78% rename from packages/server/src/adapters/fetch.test.ts rename to packages/server/src/fetch/handle.test.ts index c4e3a6103..d5eca8e98 100644 --- a/packages/server/src/adapters/fetch.test.ts +++ b/packages/server/src/fetch/handle.test.ts @@ -1,9 +1,11 @@ import { ORPC_HEADER, ORPC_HEADER_VALUE } from '@orpc/contract' +import { createOpenAPIServerHandler, createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { oz } from '@orpc/zod' import { describe, expect, it } from 'vitest' import { z } from 'zod' import { ORPCError, os } from '..' -import { createFetchHandler } from './fetch' +import { handleFetchRequest } from './handle' +import { createORPCHandler } from './handler' const router = os.router({ throw: os.func(() => { @@ -17,8 +19,6 @@ const router = os.router({ }), }) -const handler = createFetchHandler({ router }) - describe('simple', () => { const osw = os.context<{ auth?: boolean }>() const router = osw.router({ @@ -27,12 +27,11 @@ describe('simple', () => { .route({ method: 'GET', path: '/ping2' }) .func(async () => 'pong2'), }) - const handler = createFetchHandler({ - router, - }) it('200: public url', async () => { - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/ping', { method: 'POST', @@ -43,7 +42,9 @@ describe('simple', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual('pong') - const response2 = await handler({ + const response2 = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/ping2', { method: 'GET', @@ -56,15 +57,21 @@ describe('simple', () => { }) it('200: internal url', async () => { - const response = await handler({ - request: new Request('http://localhost/ping'), + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], + request: new Request('http://localhost/ping', { + method: 'POST', + }), context: { auth: true }, }) expect(response.status).toBe(200) expect(await response.json()).toEqual('pong') - const response2 = await handler({ + const response2 = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/ping2'), context: { auth: true }, @@ -75,7 +82,9 @@ describe('simple', () => { }) it('404', async () => { - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/pingp', { method: 'POST', @@ -89,7 +98,9 @@ describe('simple', () => { describe('procedure throw error', () => { it('unknown error', async () => { - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], request: new Request('https://local.com/throw', { method: 'POST' }), }) @@ -108,9 +119,9 @@ describe('procedure throw error', () => { }), }) - const handler = createFetchHandler({ router }) - - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], request: new Request('https://local.com/ping', { method: 'POST' }), }) @@ -133,9 +144,9 @@ describe('procedure throw error', () => { }), }) - const handler = createFetchHandler({ router }) - - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], request: new Request('https://local.com/ping', { method: 'POST' }), }) @@ -165,9 +176,9 @@ describe('procedure throw error', () => { }), }) - const handler = createFetchHandler({ router }) - - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], request: new Request('https://local.com/ping', { method: 'POST' }), }) @@ -178,7 +189,9 @@ describe('procedure throw error', () => { message: 'Internal server error', }) - const response2 = await handler({ + const response2 = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], request: new Request('https://local.com/ping2', { method: 'POST' }), }) @@ -200,9 +213,9 @@ describe('procedure throw error', () => { }), }) - const handler = createFetchHandler({ router }) - - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], request: new Request('https://local.com/ping', { method: 'POST' }), }) @@ -233,9 +246,9 @@ describe('procedure throw error', () => { }), }) - const handler = createFetchHandler({ router }) - - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], request: new Request('https://local.com/ping', { method: 'POST', body: '"hi"', @@ -257,8 +270,9 @@ describe('hooks', () => { const onError = vi.fn() const onFinish = vi.fn() - const handler = createFetchHandler({ + await handleFetchRequest({ router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], hooks: async (context, hooks) => { try { const response = await hooks.next() @@ -273,9 +287,6 @@ describe('hooks', () => { onFinish() } }, - }) - - await handler({ prefix: '/orpc', request: new Request('http://localhost/orpc/ping', { method: 'POST', @@ -295,8 +306,9 @@ describe('hooks', () => { const onError = vi.fn() const onFinish = vi.fn() - const handler = createFetchHandler({ + await handleFetchRequest({ router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], hooks: async (context, hooks) => { try { const response = await hooks.next() @@ -311,9 +323,6 @@ describe('hooks', () => { onFinish() } }, - }) - - await handler({ prefix: '/orpc', request: new Request('http://localhost/orpc/throw', { method: 'POST', @@ -344,8 +353,6 @@ describe('file upload', () => { }), }) - const handler = createFetchHandler({ router }) - const blob1 = new Blob(['hello'], { type: 'text/plain;charset=utf-8' }) const blob2 = new Blob(['"world"'], { type: 'image/png' }) const blob3 = new Blob(['unnoq'], { type: 'application/octet-stream' }) @@ -356,7 +363,9 @@ describe('file upload', () => { rForm.set('maps', JSON.stringify([[]])) rForm.set('0', blob3) - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/signal', { method: 'POST', @@ -385,7 +394,9 @@ describe('file upload', () => { form.set('0', blob1) form.set('1', blob2) - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/multiple', { method: 'POST', @@ -418,12 +429,11 @@ describe('accept header', () => { const router = os.router({ ping: os.func(async () => 'pong'), }) - const handler = createFetchHandler({ - router, - }) it('application/json', async () => { - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/ping', { method: 'POST', @@ -439,7 +449,9 @@ describe('accept header', () => { }) it('multipart/form-data', async () => { - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/ping', { method: 'POST', @@ -458,7 +470,9 @@ describe('accept header', () => { }) it('application/x-www-form-urlencoded', async () => { - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/ping', { method: 'POST', @@ -477,7 +491,9 @@ describe('accept header', () => { }) it('*/*', async () => { - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/ping', { method: 'POST', @@ -492,7 +508,9 @@ describe('accept header', () => { }) it('invalid', async () => { - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], prefix: '/orpc', request: new Request('http://localhost/orpc/ping', { method: 'POST', @@ -541,17 +559,20 @@ describe('dynamic params', () => { }) const handlers = [ - createFetchHandler({ + { router, - }), - createFetchHandler({ + handlers: [createORPCHandler(), createOpenAPIServerHandler()] as const, + }, + { router, - serverless: true, - }), + handlers: [createORPCHandler(), createOpenAPIServerlessHandler()] as const, + }, ] - it.each(handlers)('should handle dynamic params', async (handler) => { - const response = await handler({ + it.each(handlers)('should handle dynamic params', async ({ router, handlers }) => { + const response = await handleFetchRequest({ + router, + handlers, request: new Request('http://localhost/123'), }) @@ -560,11 +581,13 @@ describe('dynamic params', () => { expect(await response.json()).toEqual({ id: 123 }) }) - it.each(handlers)('should handle deep dynamic params', async (handler) => { + it.each(handlers)('should handle deep dynamic params', async ({ handlers }) => { const form = new FormData() form.append('file', new Blob(['hello']), 'hello.txt') - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers, request: new Request('http://localhost/123/dfdsfds', { method: 'POST', body: form, @@ -595,20 +618,17 @@ describe('can control method on POST request', () => { }) const handlers = [ - createFetchHandler({ - router, - }), - createFetchHandler({ - router, - serverless: true, - }), - ] + [createORPCHandler(), createOpenAPIServerHandler()], + [createORPCHandler(), createOpenAPIServerlessHandler()], + ] as const - it.each(handlers)('work', async (handler) => { + it.each(handlers)('work', async (...handlers) => { const form = new FormData() form.set('file', new File(['hello'], 'hello.txt')) - const response = await handler({ + const response = await handleFetchRequest({ + router, + handlers, request: new Request('http://localhost/123', { method: 'POST', body: form, @@ -617,7 +637,9 @@ describe('can control method on POST request', () => { expect(response.status).toEqual(404) - const response2 = await handler({ + const response2 = await handleFetchRequest({ + router, + handlers, request: new Request('http://localhost/123?method=PUT', { method: 'POST', body: form, diff --git a/packages/server/src/fetch/handle.ts b/packages/server/src/fetch/handle.ts new file mode 100644 index 000000000..8f6ea70b6 --- /dev/null +++ b/packages/server/src/fetch/handle.ts @@ -0,0 +1,28 @@ +import type { Router } from '../router' +import type { FetchHandler, FetchHandlerOptions } from './types' +import { ORPCError } from '@orpc/shared/error' + +export type HandleFetchRequestOptions> = FetchHandlerOptions & { + handlers: readonly [FetchHandler, ...FetchHandler[]] +} + +export async function handleFetchRequest< TRouter extends Router>( + options: HandleFetchRequestOptions, +) { + for (const handler of options.handlers) { + const response = await handler(options) + + if (response) { + return response + } + } + + const error = new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) + + return new Response(JSON.stringify(error.toJSON()), { + status: error.status, + headers: { + 'Content-Type': 'application/json', + }, + }) +} diff --git a/packages/server/src/fetch/handler.ts b/packages/server/src/fetch/handler.ts new file mode 100644 index 000000000..2ef7b2bdd --- /dev/null +++ b/packages/server/src/fetch/handler.ts @@ -0,0 +1,110 @@ +import type { Procedure } from '../procedure' +import type { Router } from '../router' +import type { FetchHandler } from './types' +import { ORPC_HEADER, ORPC_HEADER_VALUE } from '@orpc/contract' +import { trim, value } from '@orpc/shared' +import { ORPCError } from '@orpc/shared/error' +import { ORPCDeserializer, ORPCSerializer } from '@orpc/transformer' +import { isProcedure } from '../procedure' +import { createProcedureCaller } from '../procedure-caller' + +const serializer = new ORPCSerializer() +const deserializer = new ORPCDeserializer() + +export function createORPCHandler(): FetchHandler { + return async (options) => { + if (options.request.headers.get(ORPC_HEADER) !== ORPC_HEADER_VALUE) { + return undefined + } + + const context = await value(options.context) + + const handler = async () => { + const url = new URL(options.request.url) + const pathname = `/${trim(url.pathname.replace(options.prefix ?? '', ''), '/')}` + + const match = resolveORPCRouter(options.router, pathname) + + if (!match) { + throw new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) + } + + const input = await deserializeRequest(options.request) + + const caller = createProcedureCaller({ + context, + procedure: match.procedure, + path: match.path, + }) + + const output = await caller(input) + + const { body, headers } = serializer.serialize(output) + + return new Response(body, { + status: 200, + headers, + }) + } + + try { + return await options.hooks?.( + context as any, + { next: handler, response: response => response }, + ) ?? await handler() + } + catch (e) { + const error = e instanceof ORPCError + ? e + : new ORPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error', + cause: e, + }) + + const { body, headers } = serializer.serialize(error.toJSON()) + + return new Response(body, { + status: error.status, + headers, + }) + } + } +} + +function resolveORPCRouter(router: Router, pathname: string): { + path: string[] + procedure: Procedure +} | undefined { + const path = trim(pathname, '/').split('/').map(decodeURIComponent) + + let current: Router | Procedure | undefined = router + for (const segment of path) { + if ((typeof current !== 'object' || current === null) && typeof current !== 'function') { + current = undefined + break + } + + current = (current as any)[segment] + } + + return isProcedure(current) + ? { + procedure: current, + path, + } + : undefined +} + +async function deserializeRequest(request: Request): Promise { + try { + return await deserializer.deserialize(request) + } + catch (e) { + throw new ORPCError({ + code: 'BAD_REQUEST', + message: 'Cannot parse request. Please check the request body and Content-Type header.', + cause: e, + }) + } +} diff --git a/packages/server/src/fetch/index.ts b/packages/server/src/fetch/index.ts new file mode 100644 index 000000000..969d7042e --- /dev/null +++ b/packages/server/src/fetch/index.ts @@ -0,0 +1,3 @@ +export * from './handle' +export * from './handler' +export * from './types' diff --git a/packages/server/src/fetch/types.ts b/packages/server/src/fetch/types.ts new file mode 100644 index 000000000..0e4237f05 --- /dev/null +++ b/packages/server/src/fetch/types.ts @@ -0,0 +1,51 @@ +/// + +import type { PartialOnUndefinedDeep, Promisable, Value } from '@orpc/shared' +import type { Router } from '../router' + +export interface FetchHandlerHooks { + next: () => Promise + response: (response: Response) => Response +} + +export type FetchHandlerOptions< + TRouter extends Router, +> = { + /** + * The `router` used for handling the request and routing, + * + */ + router: TRouter + + /** + * The request need to be handled. + */ + request: Request + + /** + * Remove the prefix from the request path. + * + * @example /orpc + * @example /api + */ + prefix?: string + + /** + * Hooks for executing logics on lifecycle events. + */ + hooks?: ( + context: TRouter extends Router ? UContext : never, + hooks: FetchHandlerHooks, + ) => Promisable +} & PartialOnUndefinedDeep<{ + /** + * The context used to handle the request. + */ + context: Value< + TRouter extends Router ? UContext : never + > +}> + +export type FetchHandler = >( + options: FetchHandlerOptions +) => Promise diff --git a/playgrounds/contract-openapi/src/main.ts b/playgrounds/contract-openapi/src/main.ts index a64577085..f780d3f94 100644 --- a/playgrounds/contract-openapi/src/main.ts +++ b/playgrounds/contract-openapi/src/main.ts @@ -1,24 +1,12 @@ import { createServer } from 'node:http' import { generateOpenAPI } from '@orpc/openapi' -import { createFetchHandler } from '@orpc/server/fetch' +import { createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { createServerAdapter } from '@whatwg-node/server' import { contract } from './contract' import { router } from './router' import './polyfill' -const orpcHandler = createFetchHandler({ - router, - async hooks(context, hooks) { - try { - return hooks.next() - } - catch (e) { - console.error(e) - throw e - } - }, -}) - const server = createServer( createServerAdapter((request: Request) => { const url = new URL(request.url) @@ -28,10 +16,21 @@ const server = createServer( : {} if (url.pathname.startsWith('/api')) { - return orpcHandler({ + return handleFetchRequest({ + router, request, prefix: '/api', context, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], + async hooks(context, hooks) { + try { + return hooks.next() + } + catch (e) { + console.error(e) + throw e + } + }, }) } diff --git a/playgrounds/expressjs/src/main.ts b/playgrounds/expressjs/src/main.ts index 4b663adaf..3c8da22b1 100644 --- a/playgrounds/expressjs/src/main.ts +++ b/playgrounds/expressjs/src/main.ts @@ -1,5 +1,6 @@ import { generateOpenAPI } from '@orpc/openapi' -import { createFetchHandler } from '@orpc/server/fetch' +import { createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { createServerAdapter } from '@whatwg-node/server' import express from 'express' import { router } from './router' @@ -7,19 +8,6 @@ import './polyfill' const app = express() -const orpcHandler = createFetchHandler({ - router, - async hooks(context, hooks) { - try { - return hooks.next() - } - catch (e) { - console.error(e) - throw e - } - }, -}) - app.all( '/api/*', createServerAdapter((request: Request) => { @@ -27,10 +15,21 @@ app.all( ? { user: { id: 'test', name: 'John Doe', email: 'john@doe.com' } } : {} - return orpcHandler({ + return handleFetchRequest({ request, prefix: '/api', context, + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], + async hooks(context, hooks) { + try { + return hooks.next() + } + catch (e) { + console.error(e) + throw e + } + }, }) }), ) diff --git a/playgrounds/nextjs/src/app/api/[...rest]/route.ts b/playgrounds/nextjs/src/app/api/[...rest]/route.ts index 2191b851c..bac44f538 100644 --- a/playgrounds/nextjs/src/app/api/[...rest]/route.ts +++ b/playgrounds/nextjs/src/app/api/[...rest]/route.ts @@ -1,23 +1,17 @@ -import { createFetchHandler } from '@orpc/server/fetch' +import { createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { router } from './router' -const handler = createFetchHandler({ - router, - serverless: true, - // hooks(context, hooks) { - // return hooks.next() - // }, -}) - -function handleRequest(request: Request) { - return handler({ +export function GET(request: Request) { + return handleFetchRequest({ + router, request, prefix: '/api', + handlers: [createORPCHandler(), createOpenAPIServerHandler()], }) } -export const GET = handleRequest -export const POST = handleRequest -export const PUT = handleRequest -export const DELETE = handleRequest -export const PATCH = handleRequest +export const POST = GET +export const PUT = GET +export const DELETE = GET +export const PATCH = GET diff --git a/playgrounds/openapi/src/main.ts b/playgrounds/openapi/src/main.ts index 00e0119ce..facf3e4ad 100644 --- a/playgrounds/openapi/src/main.ts +++ b/playgrounds/openapi/src/main.ts @@ -1,23 +1,11 @@ import { createServer } from 'node:http' import { generateOpenAPI } from '@orpc/openapi' -import { createFetchHandler } from '@orpc/server/fetch' +import { createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { createServerAdapter } from '@whatwg-node/server' import { router } from './router' import './polyfill' -const orpcHandler = createFetchHandler({ - router, - async hooks(context, hooks) { - try { - return hooks.next() - } - catch (e) { - console.error(e) - throw e - } - }, -}) - const server = createServer( createServerAdapter((request: Request) => { const url = new URL(request.url) @@ -27,10 +15,21 @@ const server = createServer( : {} if (url.pathname.startsWith('/api')) { - return orpcHandler({ + return handleFetchRequest({ request, prefix: '/api', context, + router, + handlers: [createORPCHandler(), createOpenAPIServerHandler()], + async hooks(context, hooks) { + try { + return hooks.next() + } + catch (e) { + console.error(e) + throw e + } + }, }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 711a8d2c6..698789059 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,6 +157,9 @@ importers: specifier: workspace:* version: link:../transformer devDependencies: + '@orpc/openapi': + specifier: workspace:* + version: link:../openapi zod: specifier: ^3.23.8 version: 3.23.8 @@ -200,6 +203,9 @@ importers: '@readme/openapi-parser': specifier: ^2.6.0 version: 2.6.0(openapi-types@12.1.3) + hono: + specifier: ^4.6.12 + version: 4.6.12 zod: specifier: ^3.23.8 version: 3.23.8 @@ -247,9 +253,9 @@ importers: specifier: '>=3.23.0' version: 3.23.8 devDependencies: - hono: - specifier: ^4.6.3 - version: 4.6.6 + '@orpc/openapi': + specifier: workspace:* + version: link:../openapi packages/shared: dependencies: @@ -3239,8 +3245,8 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - hono@4.6.6: - resolution: {integrity: sha512-euUj5qwvtkG+p38GFs0LYacwaoS2hYRAGn9ysAggiwT2QBcPnT1XYUCW3hatW4C1KzAXTYuQ08BlVDJtAGuhlg==} + hono@4.6.12: + resolution: {integrity: sha512-eHtf4kSDNw6VVrdbd5IQi16r22m3s7mWPLd7xOMhg1a/Yyb1A0qpUFq8xYMX4FMuDe1nTKeMX5rTx7Nmw+a+Ag==} engines: {node: '>=16.9.0'} hosted-git-info@2.8.9: @@ -8404,7 +8410,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 - hono@4.6.6: {} + hono@4.6.12: {} hosted-git-info@2.8.9: {}