Skip to content

Commit

Permalink
feat(server)!: improve context (#96)
Browse files Browse the repository at this point in the history
* builders

* handlers

* wip

* fix

* fix docs

* fix tests

* fix naming
  • Loading branch information
unnoq authored Jan 19, 2025
1 parent a2e4a58 commit 43889a7
Show file tree
Hide file tree
Showing 59 changed files with 819 additions and 772 deletions.
2 changes: 1 addition & 1 deletion apps/content/content/docs/server/lazy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Here's how you can set up and use them:
```typescript twoslash
import { os, call } from '@orpc/server'

const pub = os.context<{ user?: { id: string } } | undefined>()
const pub = os.context<{ user?: { id: string } }>()

// Define a router with lazy loading
const router = pub.router({
Expand Down
2 changes: 1 addition & 1 deletion apps/content/content/docs/server/server-action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ When calling `.actionable()`, you can pass a context function that provides addi
import { os } from '@orpc/server'
import { z } from 'zod'

const pub = os.context<{ db: string } | undefined>()
const pub = os.context<{ db: string }>()

export const getting = pub
.input(z.object({
Expand Down
4 changes: 2 additions & 2 deletions apps/content/examples/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { ORPCError, os } from '@orpc/server'
import { oz, ZodCoercer } from '@orpc/zod'
import { z } from 'zod'

export type Context = { user?: { id: string } } | undefined
export type Context = { user?: { id: string } }

// global pub, authed completely optional
export const pub = os.context<Context>()
export const authed = pub.use(({ context, path, next }, input) => {
/** put auth logic here */
return next({})
return next()
})

export const router = pub.router({
Expand Down
2 changes: 1 addition & 1 deletion packages/openapi/src/adapters/fetch/openapi-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class OpenAPIHandler<T extends Context> implements FetchHandler<T> {
}

async handle(request: Request, ...[options]: FetchHandleRest<T>): Promise<FetchHandleResult> {
const context = options?.context as T
const context = options?.context ?? {} as T
const headers = request.headers
const accept = headers.get('accept') || undefined

Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/adapters/fetch/orpc-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Hooks } from '@orpc/shared'
import type { Context } from '../../context'
import type { Router } from '../../router'
import type { Context } from '../../types'
import type { FetchHandler, FetchHandleRest, FetchHandleResult } from './types'
import { ORPCError } from '@orpc/contract'
import { executeWithHooks, trim } from '@orpc/shared'
Expand All @@ -25,7 +25,7 @@ export class RPCHandler<T extends Context> implements FetchHandler<T> {
}

async handle(request: Request, ...[options]: FetchHandleRest<T>): Promise<FetchHandleResult> {
const context = options?.context as T
const context = options?.context ?? {} as T

const execute = async (): Promise<FetchHandleResult> => {
const url = new URL(request.url)
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/adapters/fetch/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { FetchHandler } from './types'

describe('FetchHandler', () => {
it('optional context when context is undefinable', () => {
const handler = {} as FetchHandler<{ auth: boolean } | undefined>
it('optional context when all context is optional', () => {
const handler = {} as FetchHandler<{ auth?: boolean }>

handler.handle(new Request('https://example.com'))
handler.handle(new Request('https://example.com'), { context: { auth: true } })
Expand Down
9 changes: 6 additions & 3 deletions packages/server/src/adapters/fetch/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { HTTPPath } from '@orpc/contract'
import type { Context } from '../../types'
import type { Context } from '../../context'

export type FetchHandleOptions<T extends Context> =
& { prefix?: HTTPPath }
& (undefined extends T ? { context?: T } : { context: T })
& (Record<never, never> extends T ? { context?: T } : { context: T })

export type FetchHandleRest<T extends Context> =
| [options: FetchHandleOptions<T>]
| (Record<never, never> extends T ? [] : never)

export type FetchHandleRest<T extends Context> = [options: FetchHandleOptions<T>] | (undefined extends T ? [] : never)
export type FetchHandleResult = { matched: true, response: Response } | { matched: false, response: undefined }

export interface FetchHandler<T extends Context> {
Expand Down
12 changes: 7 additions & 5 deletions packages/server/src/adapters/hono/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import type { Context as HonoContext, MiddlewareHandler } from 'hono'
import type { Context } from '../../types'
import type { Context } from '../../context'
import type { FetchHandleOptions, FetchHandler } from '../fetch'
import { value, type Value } from '@orpc/shared'

export type CreateMiddlewareOptions<T extends Context> =
& Omit<FetchHandleOptions<T>, 'context'>
& (undefined extends T ? { context?: Value<T, [HonoContext]> } : { context: Value<T, [HonoContext]> })
& (Record<never, never> extends T ? { context?: Value<T, [HonoContext]> } : { context: Value<T, [HonoContext]> })

export type CreateMiddlewareRest<T extends Context> = [options: CreateMiddlewareOptions<T>] | (undefined extends T ? [] : never)
export type CreateMiddlewareRest<T extends Context> =
| [options: CreateMiddlewareOptions<T>]
| (Record<never, never> extends T ? [] : never)

export function createMiddleware<T extends Context>(handler: FetchHandler<T>, ...[options]: CreateMiddlewareRest<T>): MiddlewareHandler {
return async (c, next) => {
const context = await value(options?.context, c)
const context = await value(options?.context ?? {}, c) as any

const { matched, response } = await handler.handle(c.req.raw, { ...options, context } as any)
const { matched, response } = await handler.handle(c.req.raw, { ...options, context })

if (matched) {
c.res = response
Expand Down
10 changes: 6 additions & 4 deletions packages/server/src/adapters/next/serve.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { NextRequest } from 'next/server'
import type { Context } from '../../types'
import type { Context } from '../../context'
import type { FetchHandleOptions, FetchHandler } from '../fetch'
import { value, type Value } from '@orpc/shared'

export type ServeOptions<T extends Context> =
& Omit<FetchHandleOptions<T>, 'context'>
& (undefined extends T ? { context?: Value<T, [NextRequest]> } : { context: Value<T, [NextRequest]> })
& (Record<never, never> extends T ? { context?: Value<T, [NextRequest]> } : { context: Value<T, [NextRequest]> })

export type ServeRest<T extends Context> = [options: ServeOptions<T>] | (undefined extends T ? [] : never)
export type ServeRest<T extends Context> =
| [options: ServeOptions<T>]
| (Record<never, never> extends T ? [] : never)

export interface ServeResult {
GET: (req: NextRequest) => Promise<Response>
Expand All @@ -19,7 +21,7 @@ export interface ServeResult {

export function serve<T extends Context>(handler: FetchHandler<T>, ...[options]: ServeRest<T>): ServeResult {
const main = async (req: NextRequest) => {
const context = await value(options?.context, req) as any
const context = await value(options?.context ?? {}, req) as any

const { matched, response } = await handler.handle(req, { ...options, context })

Expand Down
12 changes: 6 additions & 6 deletions packages/server/src/adapters/node/orpc-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ServerResponse } from 'node:http'
import type { Context } from '../../context'
import type { Router } from '../../router'
import type { Context } from '../../types'
import type { RPCHandlerOptions } from '../fetch/orpc-handler'
import type { RequestHandler, RequestHandleRest, RequestHandleResult } from './types'
import { RPCHandler as ORPCFetchHandler } from '../fetch/orpc-handler'
Expand All @@ -13,18 +13,18 @@ export class RPCHandler<T extends Context> implements RequestHandler<T> {
this.orpcFetchHandler = new ORPCFetchHandler(router, options)
}

async handle(req: ExpressableIncomingMessage, res: ServerResponse, ...[options]: RequestHandleRest<T>): Promise<RequestHandleResult> {
async handle(req: ExpressableIncomingMessage, res: ServerResponse, ...rest: RequestHandleRest<T>): Promise<RequestHandleResult> {
const request = createRequest(req, res)

const castedOptions = (options ?? {}) as Exclude<typeof options, undefined>

const result = await this.orpcFetchHandler.handle(request, castedOptions)
const result = await this.orpcFetchHandler.handle(request, ...rest)

if (result.matched === false) {
return { matched: false }
}

await options?.beforeSend?.(result.response, castedOptions.context as T)
const context = rest[0]?.context ?? {} as T

await rest[0]?.beforeSend?.(result.response, context)

await sendResponse(res, result.response)

Expand Down
8 changes: 5 additions & 3 deletions packages/server/src/adapters/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import type { HTTPPath } from '@orpc/contract'
import type { Promisable } from '@orpc/shared'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Context } from '../../types'
import type { Context } from '../../context'

export type RequestHandleOptions<T extends Context> =
& { prefix?: HTTPPath, beforeSend?: (response: Response, context: T) => Promisable<void> }
& (undefined extends T ? { context?: T } : { context: T })
& (Record<never, never> extends T ? { context?: T } : { context: T })

export type RequestHandleRest<T extends Context> = [options: RequestHandleOptions<T>] | (undefined extends T ? [] : never)
export type RequestHandleRest<T extends Context> =
| [options: RequestHandleOptions<T>]
| (Record<never, never> extends T ? [] : never)

export type RequestHandleResult = { matched: true } | { matched: false }

Expand Down
40 changes: 20 additions & 20 deletions packages/server/src/builder-with-errors-middlewares.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { BuilderWithErrorsMiddlewares } from './builder-with-errors-middlewares'
import type { Context } from './context'
import type { ORPCErrorConstructorMap } from './error'
import type { Lazy } from './lazy'
import type { MiddlewareOutputFn } from './middleware'
Expand All @@ -8,7 +9,6 @@ import type { ProcedureBuilderWithInput } from './procedure-builder-with-input'
import type { ProcedureBuilderWithOutput } from './procedure-builder-with-output'
import type { DecoratedProcedure } from './procedure-decorated'
import type { AdaptedRouter, RouterBuilder } from './router-builder'
import type { WELL_CONTEXT } from './types'
import { z } from 'zod'

const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) })
Expand All @@ -26,11 +26,11 @@ const errors = {
},
}

const builder = {} as BuilderWithErrorsMiddlewares<{ db: string }, { auth?: boolean }, typeof baseErrors>
const builder = {} as BuilderWithErrorsMiddlewares<{ db: string }, { db: string, auth?: boolean }, typeof baseErrors>

describe('BuilderWithErrorsMiddlewares', () => {
it('.errors', () => {
expectTypeOf(builder.errors(errors)).toEqualTypeOf<BuilderWithErrorsMiddlewares<{ db: string }, { auth?: boolean }, typeof errors & typeof baseErrors>>()
expectTypeOf(builder.errors(errors)).toEqualTypeOf<BuilderWithErrorsMiddlewares<{ db: string }, { db: string, auth?: boolean }, typeof errors & typeof baseErrors>>()

// @ts-expect-error --- not allow redefine error map
builder.errors({ BASE: baseErrors.BASE })
Expand All @@ -39,7 +39,7 @@ describe('BuilderWithErrorsMiddlewares', () => {
it('.use', () => {
const applied = builder.use(({ context, next, path, procedure, errors }, input, output) => {
expectTypeOf(input).toEqualTypeOf<unknown>()
expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>()
expectTypeOf(context).toEqualTypeOf<{ db: string, auth?: boolean }>()
expectTypeOf(path).toEqualTypeOf<string[]>()
expectTypeOf(procedure).toEqualTypeOf<ANY_PROCEDURE>()
expectTypeOf(output).toEqualTypeOf<MiddlewareOutputFn<unknown>>()
Expand All @@ -52,7 +52,7 @@ describe('BuilderWithErrorsMiddlewares', () => {
})
})

expectTypeOf(applied).toEqualTypeOf < BuilderWithErrorsMiddlewares < { db: string }, { auth?: boolean } & { extra: boolean }, typeof baseErrors>>()
expectTypeOf(applied).toEqualTypeOf < BuilderWithErrorsMiddlewares < { db: string }, { db: string, auth?: boolean } & { extra: boolean }, typeof baseErrors>>()

// @ts-expect-error --- conflict context
builder.use(({ next }) => next({ db: 123 }))
Expand All @@ -64,26 +64,26 @@ describe('BuilderWithErrorsMiddlewares', () => {

it('.route', () => {
expectTypeOf(builder.route({ path: '/test', method: 'GET' })).toEqualTypeOf<
ProcedureBuilder<{ db: string }, { auth?: boolean }, typeof baseErrors>
ProcedureBuilder<{ db: string }, { db: string, auth?: boolean }, typeof baseErrors>
>()
})

it('.input', () => {
expectTypeOf(builder.input(schema)).toEqualTypeOf<
ProcedureBuilderWithInput<{ db: string }, { auth?: boolean }, typeof schema, typeof baseErrors>
ProcedureBuilderWithInput<{ db: string }, { db: string, auth?: boolean }, typeof schema, typeof baseErrors>
>()
})

it('.output', () => {
expectTypeOf(builder.output(schema)).toEqualTypeOf<
ProcedureBuilderWithOutput<{ db: string }, { auth?: boolean }, typeof schema, typeof baseErrors>
ProcedureBuilderWithOutput<{ db: string }, { db: string, auth?: boolean }, typeof schema, typeof baseErrors>
>()
})

it('.handler', () => {
const procedure = builder.handler(({ input, context, procedure, path, signal, errors }) => {
expectTypeOf(input).toEqualTypeOf<unknown>()
expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>()
expectTypeOf(context).toEqualTypeOf<{ db: string, auth?: boolean }>()
expectTypeOf(procedure).toEqualTypeOf<ANY_PROCEDURE>()
expectTypeOf(path).toEqualTypeOf<string[]>()
expectTypeOf(signal).toEqualTypeOf<undefined | InstanceType<typeof AbortSignal>>()
Expand All @@ -93,26 +93,26 @@ describe('BuilderWithErrorsMiddlewares', () => {
})

expectTypeOf(procedure).toMatchTypeOf<
DecoratedProcedure<{ db: string }, { auth?: boolean }, undefined, undefined, number, typeof baseErrors>
DecoratedProcedure<{ db: string }, { db: string, auth?: boolean }, undefined, undefined, number, typeof baseErrors>
>()
})

it('.prefix', () => {
expectTypeOf(builder.prefix('/test')).toEqualTypeOf<
RouterBuilder<{ db: string }, { auth?: boolean }, typeof baseErrors>
RouterBuilder<{ db: string }, { db: string, auth?: boolean }, typeof baseErrors>
>()
})

it('.tag', () => {
expectTypeOf(builder.tag('test', 'test2')).toEqualTypeOf<
RouterBuilder<{ db: string }, { auth?: boolean }, typeof baseErrors>
RouterBuilder<{ db: string }, { db: string, auth?: boolean }, typeof baseErrors>
>()
})

it('.router', () => {
const router = {
ping: {} as Procedure<{ db: string }, { auth?: boolean }, undefined, undefined, unknown, typeof errors>,
pong: {} as Procedure<WELL_CONTEXT, undefined, undefined, undefined, unknown, Record<never, never>>,
ping: {} as Procedure<{ db: string }, { db: string, auth?: boolean }, undefined, undefined, unknown, typeof errors>,
pong: {} as Procedure<Context, Context, undefined, undefined, unknown, Record<never, never>>,
}

expectTypeOf(builder.router(router)).toEqualTypeOf<
Expand All @@ -121,7 +121,7 @@ describe('BuilderWithErrorsMiddlewares', () => {

builder.router({
// @ts-expect-error - context is not match
ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>,
ping: {} as Procedure<{ auth: 'invalid' }, Context, undefined, undefined, unknown, typeof errors>,
})

const invalidErrorMap = {
Expand All @@ -133,14 +133,14 @@ describe('BuilderWithErrorsMiddlewares', () => {

builder.router({
// @ts-expect-error - error map is not match
ping: {} as ContractProcedure<undefined, typeof schema, typeof invalidErrorMap>,
ping: {} as Procedure<Context, Context, undefined, undefined, unknown, typeof invalidErrorMap>,
})
})

it('.lazy', () => {
const router = {
ping: {} as Procedure<{ db: string }, { auth?: boolean }, undefined, undefined, unknown, typeof errors>,
pong: {} as Procedure<WELL_CONTEXT, undefined, undefined, undefined, unknown, Record<never, never>>,
ping: {} as Procedure<{ db: string }, { db: string, auth?: boolean }, undefined, undefined, unknown, typeof errors>,
pong: {} as Procedure<Context, Context, undefined, undefined, unknown, Record<never, never>>,
}

expectTypeOf(builder.lazy(() => Promise.resolve({ default: router }))).toEqualTypeOf<
Expand All @@ -149,13 +149,13 @@ describe('BuilderWithErrorsMiddlewares', () => {

// @ts-expect-error - context is not match
builder.lazy(() => Promise.resolve({ default: {
ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>,
ping: {} as Procedure<{ auth: 'invalid' }, Context, undefined, undefined, unknown, typeof errors>,
} }))

// @ts-expect-error - error map is not match
builder.lazy(() => Promise.resolve({
default: {
ping: {} as Procedure<WELL_CONTEXT, undefined, undefined, undefined, unknown, { BASE: { message: 'invalid' } }>,
ping: {} as Procedure<Context, Context, undefined, undefined, unknown, { BASE: { message: 'invalid' } }>,
},
}))
})
Expand Down
Loading

0 comments on commit 43889a7

Please sign in to comment.