Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions apps/content/docs/integrations/effect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,21 +134,40 @@ This integration preserves the original error whenever possible. If you call `Ef

To customize this behavior, wrap the effect before execution using `effect/wrap` in the context:

```ts
import { Context, Effect } from 'effect'
```ts twoslash
import type { WithEffectContext } from '@orpc/experimental-effect'
import { RPCHandler } from '@orpc/server/fetch'

interface ServerContext extends WithEffectContext<never> {}

declare const handler: RPCHandler<ServerContext>
// ---cut---
import { ORPCError } from '@orpc/server'
import { Cause, Context, Data, Effect } from 'effect'

class NotFoundError extends Data.TaggedError('NotFoundError')<{
readonly resource: string
}> {}

export async function fetch(request: Request) {
const { matched, response } = await handler.handle(request, {
context: {
'effect/context': Context.empty(),
'effect/wrap': (effect, opts) => effect.pipe(
'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)
}),
),
}
},
})

if (matched) {
Expand All @@ -159,6 +178,8 @@ export async function fetch(request: Request) {
}
```

The wrap runs with `effect/context`, once per `handlerGen` and `middlewareGen` (see `opts.kind`). Keep it idempotent, since failures from inner layers pass through it again.

:::info
For app level error handling, we recommend [middleware](/docs/middleware) or interceptors.
:::
Expand Down
106 changes: 106 additions & 0 deletions packages/effect/src/context.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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'

class Service1 extends Context.Service<
Service1,
{
readonly id: 'Service1'
}
>()('Service1') {}

class Service2 extends Context.Service<
Service2,
{
readonly id: 'Service2'
}
>()('Service2') {}

class DomainError extends Error {}

type Wrap = NonNullable<WithEffectContext<Service1>['effect/wrap']>

describe('WithEffectContext', () => {
it('effect/context', () => {
expectTypeOf<WithEffectContext<Service1>['effect/context']>().toEqualTypeOf<Context.Context<Service1>>()
})

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 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 }))
}

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'))
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))
void serviceOption

// @ts-expect-error - wrap cannot require services
const requireService: Wrap = effect => Effect.gen(function* () {
yield* Service2
return yield* effect
})
void requireService
})

it('receives options discriminated by kind', () => {
const wrap: Wrap = (effect, opts) => {
expectTypeOf(opts).toEqualTypeOf<EffectWrapOptions>()
expectTypeOf(opts.kind).toEqualTypeOf<'middleware' | 'handler'>()
expectTypeOf(opts.context).toEqualTypeOf<ORPCContext>()
expectTypeOf(opts.input).toEqualTypeOf<unknown>()
expectTypeOf(opts.path).toEqualTypeOf<string[]>()
expectTypeOf(opts.procedure).toExtend<AnyProcedure>()
expectTypeOf(opts.signal).toEqualTypeOf<AbortSignal | undefined>()
expectTypeOf(opts.lastEventId).toEqualTypeOf<string | undefined>()
expectTypeOf(opts.errors).toEqualTypeOf<ORPCErrorConstructorMap<any>>()

if (opts.kind === 'middleware') {
expectTypeOf(opts).toEqualTypeOf<EffectWrapMiddlewareOptions>()
expectTypeOf(opts).not.toHaveProperty('next')
}
else {
expectTypeOf(opts).toEqualTypeOf<EffectWrapHandlerOptions>()
}

return effect
}

void wrap
})
})
})

describe('InferEffectServices', () => {
it('infers services from effect/context', () => {
expectTypeOf<InferEffectServices<WithEffectContext<Service1 | Service2>>>().toEqualTypeOf<Service1 | Service2>()
expectTypeOf<InferEffectServices<WithEffectContext<never>>>().toEqualTypeOf<never>()
expectTypeOf<InferEffectServices<{ auth: boolean }>>().toEqualTypeOf<never>()
})

it('accepts a context with more services than required', () => {
expectTypeOf<WithEffectContext<Service1 | Service2>>().toExtend<WithEffectContext<Service1>>()
})
})
60 changes: 49 additions & 11 deletions packages/effect/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
import type { AnyProcedure, Context } from '@orpc/server'
import type { Context, MiddlewareOptions, ORPCErrorConstructorMap, ProcedureHandlerOptions } from '@orpc/server'
import type { Effect, Context as EffectContext } from 'effect'

/**
* The options `effect/wrap` receives when wrapping the effect of a `middlewareGen`.
*
* @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling}
*/
export interface EffectWrapMiddlewareOptions extends Omit<MiddlewareOptions<Context, unknown, ORPCErrorConstructorMap<any>>, 'next'> {
kind: 'middleware'
/**
* The middleware's input, validated by the schemas defined before it.
*/
input: unknown
}

/**
* The options `effect/wrap` receives when wrapping the effect of a `handlerGen`.
*
* @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling}
*/
export interface EffectWrapHandlerOptions extends ProcedureHandlerOptions<Context, unknown, ORPCErrorConstructorMap<any>> {
kind: 'handler'
}

/**
* The options `effect/wrap` receives, discriminated by `kind`.
*
* @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling}
*/
export type EffectWrapOptions = EffectWrapMiddlewareOptions | EffectWrapHandlerOptions

/**
* A context shape that provides Effect services to effectful handlers
* through the oRPC context in a typesafe way.
Expand All @@ -12,6 +41,8 @@ export interface WithEffectContext<Services> {
* A pre-built Effect context providing the services available within this oRPC context.
* Automatically provided to any effect that runs under this context.
*
* References such as `Tracer`, `Logger` or `Scheduler` can be supplied here too.
*
* @example
* ```ts
* import type { WithEffectContext } from '@orpc/experimental-effect'
Expand All @@ -33,32 +64,39 @@ export interface WithEffectContext<Services> {
['effect/context']: EffectContext.Context<Services>

/**
* An optional hook to wrap any effect before it is executed within this oRPC context.
* Useful for adding observability, tracing, or error handling.
* An optional hook to wrap an effect before it is executed within this oRPC context.
* Useful for customizing error handling, such as mapping errors to `ORPCError`.
*
* Runs for every effectful middleware and handler, so provide services through `effect/context` instead.
* Runs with `effect/context`, once per `middlewareGen` and `handlerGen` (see `opts.kind`), so provide
* services through `effect/context` rather than here. Keep it idempotent, since failures from inner
* layers pass through it again.
*
* @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling}
*
* @example
* ```ts
* import type { WithEffectContext } from '@orpc/experimental-effect'
* import { Context, Effect } from 'effect'
* import { ORPCError } from '@orpc/server'
* import { Context, Data, Effect } from 'effect'
*
* class NotFoundError extends Data.TaggedError('NotFoundError') {}
*
* interface ServerContext extends WithEffectContext<never> {}
*
* const context: ServerContext = {
* 'effect/context': Context.empty(),
* 'effect/wrap': (effect, opts) => effect.pipe(
* Effect.withSpan(opts.path.join('.')),
* 'effect/wrap': effect => effect.pipe(
* Effect.mapError(error => error instanceof NotFoundError
* ? new ORPCError('NOT_FOUND', { cause: error })
* : error),
* ),
* }
* ```
*/
['effect/wrap']?: <A, E>(
effect: Effect.Effect<A, E>,
opts: { path: string[], procedure: AnyProcedure, signal?: undefined | AbortSignal },
) => Effect.Effect<A, E>
['effect/wrap']?: <A>(
effect: Effect.Effect<A, unknown>,
opts: EffectWrapOptions,
) => Effect.Effect<A, unknown>
}

/**
Expand Down
Loading
Loading