From a76421fdbe3a15143cc22f79d65fe32b740a5b60 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:18:28 +0000 Subject: [PATCH 1/6] feat(effect): redesign effect/wrap to change errors and run within effect/context - `effect/wrap` now returns `Effect`, so a wrap can map failures or defects to `ORPCError` (e.g. `Effect.mapError`, `Effect.catchCause`). - Effects run with `Effect.runPromiseExitWith(effect/context)` instead of `Effect.provide`, so the wrap sees the same services, Tracer, Logger and Scheduler as the handler. Services provided by the wrap now take precedence over `effect/context` for the effect it wraps. - The wrap still runs once per `middlewareGen` and once per `handlerGen`; its options are now a discriminated union (`EffectWrapOptions`) on `kind`, carry the full layer options, and for middleware `input` is the middleware's validated input (and `next` is omitted). - `runPromise` accepts a `context` and Effect's `RunOptions`. - Docs: fix the Error Handling example (twoslash, real `catchCause` body), document wrap semantics, supplying references via `effect/context`, and update the OpenTelemetry example to `@effect/opentelemetry` v4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BQ43QtcK3ZzzFbbxjAGFeg --- apps/content/docs/integrations/effect.mdx | 65 ++++++-- packages/effect/src/context.test-d.ts | 99 ++++++++++++ packages/effect/src/context.ts | 67 ++++++-- packages/effect/src/handler.test-d.ts | 44 +++++- packages/effect/src/handler.test.ts | 183 ++++++++++++++++++---- packages/effect/src/handler.ts | 18 +-- packages/effect/src/middleware.test-d.ts | 28 +++- packages/effect/src/middleware.test.ts | 166 ++++++++++++++++++-- packages/effect/src/middleware.ts | 19 +-- packages/effect/src/runtime.ts | 14 +- 10 files changed, 608 insertions(+), 95 deletions(-) create mode 100644 packages/effect/src/context.test-d.ts diff --git a/apps/content/docs/integrations/effect.mdx b/apps/content/docs/integrations/effect.mdx index 5e9fa3f63e..b085527417 100644 --- a/apps/content/docs/integrations/effect.mdx +++ b/apps/content/docs/integrations/effect.mdx @@ -116,27 +116,58 @@ const procedure = os ::: +`effect/context` also accepts Effect references such as `Tracer`, `Logger` or `Scheduler`, which then apply to every effect that runs under this context. For example, Effect recommends a sync scheduler on Cloudflare Workers: + +```ts +import { Context, Scheduler } from 'effect' + +const effectContext = Context.empty().pipe( + Context.add(Scheduler.Scheduler, new Scheduler.MixedScheduler('sync')), +) +``` + ### Error Handling This integration preserves the original error whenever possible. If you call `Effect.fail(error)`, the error is forwarded to [middleware](/docs/middleware) and interceptors, just like a regular thrown error. -To customize this behavior, wrap the effect before execution using `effect/wrap` in the context: +To customize this behavior, wrap the effect before execution using `effect/wrap` in the context. The wrap may change the error type, for example to turn domain errors into `ORPCError`: -```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 {} +declare const handler: RPCHandler +// ---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.catchCause((cause) => { - - }) + 'effect/wrap': effect => effect.pipe( + // also catches defects, such as errors thrown inside the generator + Effect.catchCause((cause): Effect.Effect => { + const error = Cause.squash(cause) + + if (error instanceof NotFoundError) { + return Effect.fail(new ORPCError('NOT_FOUND', { + message: `${error.resource} not found`, + cause: error, + })) + } + + // keep everything else, including interruptions, as-is + return Effect.failCause(cause) + }), ), - } + }, }) if (matched) { @@ -147,6 +178,10 @@ export async function fetch(request: Request) { } ``` +The wrap runs once for every `handlerGen` and `middlewareGen` in a request, each with its own effect. `opts.kind` (`'handler'` or `'middleware'`) tells them apart, and `opts` also holds that layer's `context`, `input`, `path`, `procedure`, `errors` and `signal`. Failures from inner layers reach outer `middlewareGen`s through `next`, so they pass through the wrap again. Keep the wrap idempotent, as the example above is: an `ORPCError` that is already mapped passes through unchanged. + +The wrapped effect runs with `effect/context`, so code in the wrap sees the same services, `Tracer` and `Logger` as the handler. Services the wrap provides take precedence over `effect/context` for the effect it wraps. + :::info For app level error handling, we recommend [middleware](/docs/middleware) or interceptors. ::: @@ -336,23 +371,21 @@ const converter = new EffectSchemaToJsonSchemaConverter({ cache: true }) ## OpenTelemetry Integration -First, set up the [oRPC OpenTelemetry integration](/docs/integrations/opentelemetry). Then instrument your Effect to work seamlessly with OpenTelemetry by providing `TracingLive` through `effect/wrap` in the context. This makes Effect tracing equivalent to OpenTelemetry tracing: +First, set up the [oRPC OpenTelemetry integration](/docs/integrations/opentelemetry). Then install [`@effect/opentelemetry`](https://www.npmjs.com/package/@effect/opentelemetry) and provide its tracer through `effect/context`. This makes Effect tracing equivalent to OpenTelemetry tracing, both in handlers and in `effect/wrap`: ```ts -import { Resource, Tracer } from '@effect/opentelemetry' -import { Context, Effect, Layer } from 'effect' +import { OtelTracer } from '@effect/opentelemetry' +import { ManagedRuntime } from 'effect' interface ServerContext extends WithEffectContext {} -const TracingLive = Tracer.layerGlobal.pipe( - Layer.provide(Resource.layerFromEnv()), -) +// Build the Effect tracer, backed by the global OpenTelemetry tracer provider, once +const effectContext = await ManagedRuntime.make(OtelTracer.layerGlobal).context() 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.provide(TracingLive)), + 'effect/context': effectContext, } }) diff --git a/packages/effect/src/context.test-d.ts b/packages/effect/src/context.test-d.ts new file mode 100644 index 0000000000..1be05bc28b --- /dev/null +++ b/packages/effect/src/context.test-d.ts @@ -0,0 +1,99 @@ +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') {} + +type Wrap = NonNullable['effect/wrap']> + +describe('WithEffectContext', () => { + it('effect/context', () => { + expectTypeOf['effect/context']>().toEqualTypeOf>() + }) + + 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 catchCause: Wrap = effect => effect.pipe( + Effect.catchCause(cause => Effect.fail(new ORPCError('INTERNAL_SERVER_ERROR', { cause: Cause.squash(cause) }))), + ) + + const fail: Wrap = () => Effect.fail(new ORPCError('UNAUTHORIZED')) + + void mapError + void catchCause + void fail + + // @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() + expectTypeOf(opts.kind).toEqualTypeOf<'middleware' | 'handler'>() + expectTypeOf(opts.context).toEqualTypeOf() + expectTypeOf(opts.input).toEqualTypeOf() + expectTypeOf(opts.path).toEqualTypeOf() + expectTypeOf(opts.procedure).toExtend() + expectTypeOf(opts.signal).toEqualTypeOf() + expectTypeOf(opts.lastEventId).toEqualTypeOf() + expectTypeOf(opts.errors).toEqualTypeOf>() + + if (opts.kind === 'middleware') { + expectTypeOf(opts).toEqualTypeOf() + expectTypeOf(opts).not.toHaveProperty('next') + } + else { + expectTypeOf(opts).toEqualTypeOf() + } + + return effect.pipe(Effect.withSpan(`${opts.kind}:${opts.path.join('.')}`)) + } + + void wrap + }) + }) +}) + +describe('InferEffectServices', () => { + it('infers services from effect/context', () => { + expectTypeOf>>().toEqualTypeOf() + expectTypeOf>>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + }) + + it('accepts a context with more services than required', () => { + expectTypeOf>().toExtend>() + }) +}) diff --git a/packages/effect/src/context.ts b/packages/effect/src/context.ts index 897fe5c5c9..3965ee5f5f 100644 --- a/packages/effect/src/context.ts +++ b/packages/effect/src/context.ts @@ -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>, 'next'> { + kind: 'middleware' + /** + * The input the middleware receives, validated by the input 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> { + 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. @@ -12,6 +41,8 @@ export interface WithEffectContext { * A pre-built Effect context providing the services available within this oRPC context. * Automatically provided to any effect that runs under this context. * + * References such as `Tracer`, `Logger` or `Scheduler` can be supplied here too. + * * @example * ```ts * import { Context } from 'effect' @@ -28,30 +59,44 @@ export interface WithEffectContext { ['effect/context']: EffectContext.Context /** - * An optional hook to wrap any effect before it is executed within this oRPC context. + * An optional hook to wrap an effect before it is executed within this oRPC context. * Useful for adding observability, tracing, or error handling. * + * - Runs once per `middlewareGen` and once per `handlerGen` of a request, + * each with its own effect. Use `opts.kind` to tell them apart, and keep the wrap + * idempotent: failures of inner layers reach outer `middlewareGen`s through `next`, + * so they pass through the wrap again. + * - The wrapped effect runs with `effect/context`, so the wrap sees the same services + * (`Tracer`, `Logger`, ...) as the handler. Services the wrap provides take precedence + * over `effect/context` for the effect it wraps. + * - The returned effect may fail with any error, which is thrown as-is. + * + * @see {@link https://orpc.dev/docs/integrations/effect#error-handling | Effect Integration - Error Handling} + * * @example * ```ts - * import { Resource, Tracer } from '@effect/opentelemetry' - * import { Context, Effect, Layer } from 'effect' + * import { ORPCError } from '@orpc/server' + * import { Context, Data, Effect } from 'effect' * - * interface ServerContext extends WithEffectContext {} + * class NotFoundError extends Data.TaggedError('NotFoundError') {} * - * const TracingLive = Tracer.layerGlobal.pipe( - * Layer.provide(Resource.layerFromEnv()), - * ) + * interface ServerContext extends WithEffectContext {} * * const context: ServerContext = { * 'effect/context': Context.empty(), - * 'effect/wrap': (effect) => effect.pipe(Effect.provide(TracingLive)), + * 'effect/wrap': (effect, opts) => effect.pipe( + * Effect.mapError(error => error instanceof NotFoundError + * ? new ORPCError('NOT_FOUND', { cause: error }) + * : error), + * Effect.withSpan(`${opts.kind}:${opts.path.join('.')}`), + * ), * } * ``` */ ['effect/wrap']?: ( effect: Effect.Effect, - opts: { path: string[], procedure: AnyProcedure, signal?: undefined | AbortSignal }, - ) => Effect.Effect + opts: EffectWrapOptions, + ) => Effect.Effect } /** diff --git a/packages/effect/src/handler.test-d.ts b/packages/effect/src/handler.test-d.ts index 12f18e1b01..abd9a40135 100644 --- a/packages/effect/src/handler.test-d.ts +++ b/packages/effect/src/handler.test-d.ts @@ -1,8 +1,8 @@ import type { InitialInputSchema, Schema } from '@orpc/contract' import type { DecoratedProcedure, DefaultInitialContext, ORPCErrorConstructorMap } from '@orpc/server' import type { WithEffectContext } from './context' -import { ORPCError, os } from '@orpc/server' -import { Context, Effect } from 'effect' +import { call, ORPCError, os } from '@orpc/server' +import { Cause, Context, Effect } from 'effect' import { z } from 'zod' import { handlerGen } from './handler' @@ -108,4 +108,44 @@ describe('handlerGen', () => { yield* Service2 })) }) + + it('accepts effect/wrap that changes the error type', async () => { + const procedure = os + .$context>() + .handler(handlerGen(function* () { + const service1 = yield* Service1 + return service1.id + })) + + const effectContext = Context.empty().pipe(Context.add(Service1, { id: 'Service1' })) + + await call(procedure, undefined, { + context: { + 'effect/context': effectContext, + 'effect/wrap': effect => effect.pipe( + Effect.mapError(error => new ORPCError('BAD_REQUEST', { cause: error })), + ), + }, + }) + + await call(procedure, undefined, { + context: { + 'effect/context': effectContext, + 'effect/wrap': (effect, opts) => effect.pipe( + Effect.catchCause(cause => Effect.fail(new ORPCError('INTERNAL_SERVER_ERROR', { + message: opts.kind, + cause: Cause.squash(cause), + }))), + ), + }, + }) + + await call(procedure, undefined, { + context: { + 'effect/context': effectContext, + // @ts-expect-error - wrap must keep the success type + 'effect/wrap': effect => effect.pipe(Effect.as('invalid')), + }, + }) + }) }) diff --git a/packages/effect/src/handler.test.ts b/packages/effect/src/handler.test.ts index 30fd6a843e..00cfaff69d 100644 --- a/packages/effect/src/handler.test.ts +++ b/packages/effect/src/handler.test.ts @@ -1,6 +1,7 @@ -import type { WithEffectContext } from './context' +import type { EffectWrapOptions, WithEffectContext } from './context' import { call, ORPCError, os, type } from '@orpc/server' -import { Context, Effect } from 'effect' +import { Cause, Context, Effect, Option, Scheduler, Tracer } from 'effect' +import { z } from 'zod' import { handlerGen } from './handler' import * as EffectModule from './runtime' @@ -67,7 +68,7 @@ describe('handlerGen', () => { ).resolves.toEqual('output') expect(runPromiseSpy).toHaveBeenCalledTimes(1) - expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { signal: controller.signal }) + expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { context: undefined, signal: controller.signal }) }) it('can access context, input, errors, signal, ...', async () => { @@ -115,42 +116,170 @@ describe('handlerGen', () => { )).rejects.toThrow('Service2') }) - it('can wrap effect execution and receives procedure options', async () => { - let wrappedCalls = 0 - let wrappedPath: string[] | undefined - let wrappedProcedure: unknown - let wrappedSignal: unknown + it('can wrap effect execution and receives handler options', async () => { + const wrapOptions: EffectWrapOptions[] = [] + const wrap: WithEffectContext['effect/wrap'] = (effect, opts) => { + wrapOptions.push(opts) + return effect + } const procedure = os .$context>() + .input(z.object({ id: z.string().transform(Number) })) + .errors({ BASE: {} }) .handler(handlerGen(function* () { return 'output' })) const signal = AbortSignal.timeout(100) + const context = { 'effect/context': Context.empty(), 'effect/wrap': wrap } await expect(call( procedure, - undefined, - { - path: ['wrapped', 'procedure'], - context: { - 'effect/context': Context.empty(), - 'effect/wrap': (effect, opts) => effect.pipe(Effect.tap(() => Effect.sync(() => { - wrappedCalls += 1 - wrappedPath = opts.path - wrappedProcedure = opts.procedure - wrappedSignal = opts.signal - }))), - }, - signal, - }, + { id: '1' }, + { path: ['wrapped', 'procedure'], context, signal, lastEventId: 'id' }, )).resolves.toEqual('output') - expect(wrappedCalls).toBe(1) - expect(wrappedPath).toEqual(['wrapped', 'procedure']) - expect(wrappedProcedure).toBe(procedure) - expect(wrappedSignal).toBe(signal) + expect(wrapOptions).toEqual([{ + kind: 'handler', + context, + input: { id: 1 }, + path: ['wrapped', 'procedure'], + procedure, + signal, + lastEventId: 'id', + errors: { BASE: expect.any(Function) }, + }]) + }) + + it('can map failures to a different error type via wrap', async () => { + const domainError = new Error('__DOMAIN__') + + 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(error => new ORPCError('BAD_REQUEST', { cause: error })), + ), + }, + })).rejects.toSatisfy( + error => error instanceof ORPCError && error.code === 'BAD_REQUEST' && error.cause === domainError, + ) + }) + + it('can convert defects to ORPCError via wrap with catchCause', async () => { + const defect = new Error('__DEFECT__') + + 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, + ) + }) + + it('runs wrap with effect/context so wrap and handler share services and tracer', async () => { + const spans: { name: string, parent: string | undefined }[] = [] + const tracer = Tracer.make({ + span(options) { + spans.push({ + name: options.name, + parent: Option.getOrUndefined(Option.map(options.parent, parent => parent._tag === 'Span' ? parent.name : undefined)), + }) + return Tracer.nativeTracer.span(options) + }, + }) + + let serviceInWrap: Option.Option | undefined + + const procedure = os + .$context>() + .handler(handlerGen(function* () { + const service1 = yield* Service1 + return yield* Effect.succeed(`output:${service1.id}`).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:Service1') + + expect(serviceInWrap).toEqual(Option.some({ id: 'Service1' })) + expect(spans).toEqual([ + { name: 'wrap', parent: undefined }, + { name: 'handler', parent: 'wrap' }, + ]) + }) + + it('lets services provided by wrap take precedence over effect/context', async () => { + 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') + }) + + it('runs with the scheduler supplied via effect/context', async () => { + const scheduleTask = vi.fn() + + class TrackedScheduler extends Scheduler.MixedScheduler { + override makeDispatcher() { + const dispatcher = super.makeDispatcher() + const original = dispatcher.scheduleTask.bind(dispatcher) + dispatcher.scheduleTask = (task, priority) => { + scheduleTask() + original(task, priority) + } + return dispatcher + } + } + + const procedure = os + .$context>() + .handler(handlerGen(function* () { + yield* Effect.yieldNow + return 'output' + })) + + await expect(call(procedure, undefined, { + context: { + 'effect/context': Context.empty().pipe(Context.add(Scheduler.Scheduler, new TrackedScheduler('sync'))), + }, + })).resolves.toEqual('output') + + expect(scheduleTask).toHaveBeenCalled() }) it('throws ORPCError failures from wrap as-is', async () => { @@ -168,7 +297,7 @@ describe('handlerGen', () => { { context: { 'effect/context': Context.empty(), - 'effect/wrap': () => Effect.fail(error) as any, + 'effect/wrap': () => Effect.fail(error), }, }, )).rejects.toBe(error) diff --git a/packages/effect/src/handler.ts b/packages/effect/src/handler.ts index 4f3ae82832..9f7d10dc8c 100644 --- a/packages/effect/src/handler.ts +++ b/packages/effect/src/handler.ts @@ -39,17 +39,13 @@ export function handlerGen< handler: HandlerGen, ): ProcedureHandler { return (opts, input) => { - let ef = Effect.gen(() => handler(opts, input)) as Effect.Effect> + const effectContext = opts.context['effect/context'] + const wrap = opts.context['effect/wrap'] as WithEffectContext['effect/wrap'] + const ef = Effect.gen(() => handler(opts, input)) as Effect.Effect> - if (EffectContext.isContext(opts.context['effect/context'])) { - ef = ef.pipe(Effect.provide(opts.context['effect/context'])) - } - - if (typeof opts.context['effect/wrap'] === 'function') { - const intercept = opts.context['effect/wrap'] as Exclude['effect/wrap'], undefined> - ef = intercept(ef, opts) - } - - return runPromise(ef, { signal: opts.signal }) + 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/middleware.test-d.ts b/packages/effect/src/middleware.test-d.ts index 105d14f0e6..47d2403cee 100644 --- a/packages/effect/src/middleware.test-d.ts +++ b/packages/effect/src/middleware.test-d.ts @@ -1,6 +1,6 @@ import type { ORPCErrorConstructorMap } from '@orpc/server' import type { WithEffectContext } from './context' -import { os } from '@orpc/server' +import { call, ORPCError, os } from '@orpc/server' import { Context, Effect } from 'effect' import { z } from 'zod' import { middlewareGen } from './middleware' @@ -184,4 +184,30 @@ describe('middlewareGen', () => { return yield* next() })) }) + + it('accepts effect/wrap that changes the error type and narrows by kind', async () => { + const procedure = os + .$context>() + .use(middlewareGen(function* ({ next }) { + yield* Service1 + return yield* next() + })) + .handler(() => 'output') + + await call(procedure, undefined, { + context: { + 'effect/context': Context.empty().pipe(Context.add(Service1, { id: 'Service1' })), + 'effect/wrap': (effect, opts) => { + if (opts.kind === 'middleware') { + expectTypeOf(opts.input).toEqualTypeOf() + expectTypeOf(opts).not.toHaveProperty('next') + } + + return effect.pipe( + Effect.mapError(error => new ORPCError('BAD_REQUEST', { cause: error })), + ) + }, + }, + }) + }) }) diff --git a/packages/effect/src/middleware.test.ts b/packages/effect/src/middleware.test.ts index 2423f81e4d..9406a7f7a7 100644 --- a/packages/effect/src/middleware.test.ts +++ b/packages/effect/src/middleware.test.ts @@ -1,6 +1,8 @@ -import type { WithEffectContext } from './context' +import type { EffectWrapOptions, WithEffectContext } from './context' import { call, ORPCError, os } from '@orpc/server' -import { Context, Effect } from 'effect' +import { Context, Effect, Option, Tracer } from 'effect' +import { z } from 'zod' +import { handlerGen } from './handler' import { middlewareGen } from './middleware' import * as EffectModule from './runtime' @@ -121,29 +123,167 @@ describe('middlewareGen', () => { }) it('can wrap effect execution via effect/wrap', async () => { - let wrappedCalls = 0 + const wrapOptions: EffectWrapOptions[] = [] + const wrap: WithEffectContext['effect/wrap'] = (effect, opts) => { + wrapOptions.push(opts) + return effect + } const procedure = os .$context>() + .input(z.object({ id: z.string().transform(Number) })) + .errors({ BASE: {} }) .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') + + expect(wrapOptions).toEqual([{ + kind: 'middleware', + context, + // validated input of the middleware rather than the raw procedure input + input: { id: 1 }, + path: ['wrapped', 'procedure'], + procedure, + signal, + lastEventId: 'id', + errors: { BASE: expect.any(Function) }, + }]) + expect(wrapOptions[0]).not.toHaveProperty('next') + }) + + it('invokes effect/wrap once per middlewareGen and handlerGen', async () => { + const wrapOptions: EffectWrapOptions[] = [] + const wrap: WithEffectContext['effect/wrap'] = (effect, opts) => { + wrapOptions.push(opts) + return effect + } + + const procedure = os + .$context>() + .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' + })) + + await expect(call(procedure, undefined, { + context: { 'effect/context': Context.empty(), 'effect/wrap': wrap }, + })).resolves.toEqual('output') + + expect(wrapOptions.map(opts => [opts.kind, Object.keys(opts.context).filter(key => !key.startsWith('effect/'))])).toEqual([ + ['middleware', []], + ['middleware', ['first', 'plain']], + ['handler', ['first', 'plain', 'second']], + ]) + }) + + it('can map own and downstream failures to a different error type via effect/wrap', async () => { + const middlewareError = new Error('__MIDDLEWARE__') + const downstreamError = new Error('__DOWNSTREAM__') + + const context: WithEffectContext = { + 'effect/context': Context.empty(), + 'effect/wrap': (effect, opts) => effect.pipe( + Effect.mapError(error => error instanceof ORPCError + ? error + : new ORPCError('BAD_REQUEST', { message: opts.kind, cause: error })), + ), + } + + await expect(call( + os + .$context>() + .use(middlewareGen(function* () { + return yield* Effect.fail(middlewareError) + })) + .handler(() => 'unreachable'), undefined, - { - context: { - 'effect/context': Context.empty(), - 'effect/wrap': effect => effect.pipe(Effect.tap(() => Effect.sync(() => { - wrappedCalls += 1 - }))), - }, + { context }, + )).rejects.toSatisfy( + error => error instanceof ORPCError && error.code === 'BAD_REQUEST' && error.cause === middlewareError, + ) + + await expect(call( + os + .$context>() + .use(middlewareGen(function* ({ next }) { + return yield* next() + })) + .handler(() => { + throw downstreamError + }), + undefined, + { context }, + )).rejects.toSatisfy( + error => error instanceof ORPCError && error.message === 'middleware' && error.cause === downstreamError, + ) + + // already mapped by the handler wrap, passes through the middleware wrap as-is + await expect(call( + os + .$context>() + .use(middlewareGen(function* ({ next }) { + return yield* next() + })) + .handler(handlerGen(function* () { + return yield* Effect.fail(downstreamError) + })), + undefined, + { context }, + )).rejects.toSatisfy( + error => error instanceof ORPCError && error.message === 'handler' && error.cause === downstreamError, + ) + }) + + it('runs effect/wrap with effect/context so wrap and middleware share services and tracer', async () => { + const spans: string[] = [] + const tracer = Tracer.make({ + span(options) { + spans.push(options.name) + return Tracer.nativeTracer.span(options) }, - )).resolves.toEqual('output') + }) + + let serviceInWrap: Option.Option | undefined + + const procedure = os + .$context>() + .use(middlewareGen(function* ({ next }) { + const service1 = yield* Service1 + return yield* next({ context: { fromService: service1.id } }).pipe(Effect.withSpan('middleware')) + })) + .handler(({ context }) => `output:${context.fromService}`) + + 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:Service1') - expect(wrappedCalls).toBe(1) + expect(serviceInWrap).toEqual(Option.some({ id: 'Service1' })) + expect(spans).toEqual(['wrap', 'middleware']) }) it('forwards the provided signal to runPromise', async () => { @@ -160,6 +300,6 @@ describe('middlewareGen', () => { ).resolves.toEqual('output') expect(runPromiseSpy).toHaveBeenCalledTimes(1) - expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { signal: controller.signal }) + expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { context: undefined, signal: controller.signal }) }) }) diff --git a/packages/effect/src/middleware.ts b/packages/effect/src/middleware.ts index 9d106666d0..9bd4d2dc8d 100644 --- a/packages/effect/src/middleware.ts +++ b/packages/effect/src/middleware.ts @@ -2,6 +2,7 @@ import type { ErrorMap } from '@orpc/contract' 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 { omit } from '@orpc/shared' import { Effect, Context as EffectContext } from 'effect' import { runPromise } from './runtime' @@ -64,18 +65,14 @@ export function middlewareGen< catch: error => error, }) - let ef = Effect.gen(() => middleware({ ...opts, next }, input, done)) as Effect.Effect, unknown> + 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> - if (EffectContext.isContext(opts.context['effect/context'])) { - ef = ef.pipe(Effect.provide(opts.context['effect/context'])) - } - - if (typeof opts.context['effect/wrap'] === 'function') { - const intercept = opts.context['effect/wrap'] as Exclude['effect/wrap'], undefined> - ef = intercept(ef, opts) - } - - return runPromise(ef, { signal: opts.signal }) + return runPromise( + typeof wrap === 'function' ? wrap(ef, { ...omit(opts, ['next']), kind: 'middleware', input }) : ef, + { context: EffectContext.isContext(effectContext) ? effectContext : undefined, signal: opts.signal }, + ) } Object.defineProperty(mid, 'name', { value: middleware.name }) diff --git a/packages/effect/src/runtime.ts b/packages/effect/src/runtime.ts index dd2d636659..8c82580ffd 100644 --- a/packages/effect/src/runtime.ts +++ b/packages/effect/src/runtime.ts @@ -1,15 +1,23 @@ +import type { Context } from 'effect' import { AbortError } from '@orpc/shared' import { Cause, Effect, Exit } from 'effect' -export interface RunPromiseOptions { - signal?: undefined | AbortSignal +export interface RunPromiseOptions extends Effect.RunOptions { + /** + * The Effect context the effect runs with, including references such as `Tracer` or `Scheduler`. + */ + context?: undefined | Context.Context } /** * Runs an Effect as a Promise and throws the most meaningful errors. */ export async function runPromise(effect: Effect.Effect, options: RunPromiseOptions = {}): Promise { - const exit = await Effect.runPromiseExit(effect, options) + const { context, ...runOptions } = options + + const exit = context + ? await Effect.runPromiseExitWith(context)(effect, runOptions) + : await Effect.runPromiseExit(effect, runOptions) if (Exit.isSuccess(exit)) { return exit.value From 1c66d08dfae21ee9963f416065ca89c9e5ef7b36 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 02:20:20 +0000 Subject: [PATCH 2/6] refactor(effect): simplify effect/wrap typing, runtime and tests - Type the wrap's input as `Effect`: one wrap serves every handler, so the generic error type carried no information and forced a return annotation on multi-branch `catchCause` callbacks (as in the docs). Wraps written with `` remain assignable. - `runPromise` makes a single `runPromiseExitWith(context ?? Context.empty())` call and passes options straight through. - `middlewareGen` drops `next` by destructuring instead of `omit`, avoiding a copy plus `delete` on every wrapped call. - Test `runPromise`'s `context` option (services, scheduler) in runtime.test.ts, use `vi.fn`/`vi.spyOn` instead of hand-rolled recorders, and drop duplicated tracer, scheduler and type cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BQ43QtcK3ZzzFbbxjAGFeg --- apps/content/docs/integrations/effect.mdx | 2 +- packages/effect/src/context.test-d.ts | 15 ++++ packages/effect/src/context.ts | 4 +- packages/effect/src/handler.test-d.ts | 14 +--- packages/effect/src/handler.test.ts | 59 +++------------ packages/effect/src/middleware.test.ts | 92 ++++------------------- packages/effect/src/middleware.ts | 15 ++-- packages/effect/src/runtime.test.ts | 23 +++++- packages/effect/src/runtime.ts | 9 +-- 9 files changed, 78 insertions(+), 155 deletions(-) diff --git a/apps/content/docs/integrations/effect.mdx b/apps/content/docs/integrations/effect.mdx index b085527417..525323bb17 100644 --- a/apps/content/docs/integrations/effect.mdx +++ b/apps/content/docs/integrations/effect.mdx @@ -153,7 +153,7 @@ export async function fetch(request: Request) { 'effect/context': Context.empty(), 'effect/wrap': effect => effect.pipe( // also catches defects, such as errors thrown inside the generator - Effect.catchCause((cause): Effect.Effect => { + Effect.catchCause((cause) => { const error = Cause.squash(cause) if (error instanceof NotFoundError) { diff --git a/packages/effect/src/context.test-d.ts b/packages/effect/src/context.test-d.ts index 1be05bc28b..cc761972be 100644 --- a/packages/effect/src/context.test-d.ts +++ b/packages/effect/src/context.test-d.ts @@ -17,6 +17,8 @@ class Service2 extends Context.Service< } >()('Service2') {} +class DomainError extends Error {} + type Wrap = NonNullable['effect/wrap']> describe('WithEffectContext', () => { @@ -34,10 +36,23 @@ describe('WithEffectContext', () => { Effect.catchCause(cause => Effect.fail(new ORPCError('INTERNAL_SERVER_ERROR', { cause: 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 })) + } + + return Effect.failCause(cause) + }), + ) + const fail: Wrap = () => Effect.fail(new ORPCError('UNAUTHORIZED')) void mapError void catchCause + void mapSome void fail // @ts-expect-error - must keep the success type diff --git a/packages/effect/src/context.ts b/packages/effect/src/context.ts index 3965ee5f5f..8b3cff7987 100644 --- a/packages/effect/src/context.ts +++ b/packages/effect/src/context.ts @@ -93,8 +93,8 @@ export interface WithEffectContext { * } * ``` */ - ['effect/wrap']?: ( - effect: Effect.Effect, + ['effect/wrap']?: ( + effect: Effect.Effect, opts: EffectWrapOptions, ) => Effect.Effect } diff --git a/packages/effect/src/handler.test-d.ts b/packages/effect/src/handler.test-d.ts index abd9a40135..fec129f259 100644 --- a/packages/effect/src/handler.test-d.ts +++ b/packages/effect/src/handler.test-d.ts @@ -2,7 +2,7 @@ import type { InitialInputSchema, Schema } from '@orpc/contract' import type { DecoratedProcedure, DefaultInitialContext, ORPCErrorConstructorMap } from '@orpc/server' import type { WithEffectContext } from './context' import { call, ORPCError, os } from '@orpc/server' -import { Cause, Context, Effect } from 'effect' +import { Context, Effect } from 'effect' import { z } from 'zod' import { handlerGen } from './handler' @@ -128,18 +128,6 @@ describe('handlerGen', () => { }, }) - await call(procedure, undefined, { - context: { - 'effect/context': effectContext, - 'effect/wrap': (effect, opts) => effect.pipe( - Effect.catchCause(cause => Effect.fail(new ORPCError('INTERNAL_SERVER_ERROR', { - message: opts.kind, - cause: Cause.squash(cause), - }))), - ), - }, - }) - await call(procedure, undefined, { context: { 'effect/context': effectContext, diff --git a/packages/effect/src/handler.test.ts b/packages/effect/src/handler.test.ts index 00cfaff69d..f270b93835 100644 --- a/packages/effect/src/handler.test.ts +++ b/packages/effect/src/handler.test.ts @@ -1,6 +1,6 @@ import type { EffectWrapOptions, WithEffectContext } from './context' import { call, ORPCError, os, type } from '@orpc/server' -import { Cause, Context, Effect, Option, Scheduler, Tracer } from 'effect' +import { Cause, Context, Effect, Option, Tracer } from 'effect' import { z } from 'zod' import { handlerGen } from './handler' import * as EffectModule from './runtime' @@ -117,11 +117,7 @@ describe('handlerGen', () => { }) it('can wrap effect execution and receives handler options', async () => { - const wrapOptions: EffectWrapOptions[] = [] - const wrap: WithEffectContext['effect/wrap'] = (effect, opts) => { - wrapOptions.push(opts) - return effect - } + const wrap = vi.fn((effect: any, _opts: EffectWrapOptions) => effect) const procedure = os .$context>() @@ -140,7 +136,7 @@ describe('handlerGen', () => { { path: ['wrapped', 'procedure'], context, signal, lastEventId: 'id' }, )).resolves.toEqual('output') - expect(wrapOptions).toEqual([{ + expect(wrap).toHaveBeenCalledExactlyOnceWith(expect.anything(), { kind: 'handler', context, input: { id: 1 }, @@ -149,7 +145,7 @@ describe('handlerGen', () => { signal, lastEventId: 'id', errors: { BASE: expect.any(Function) }, - }]) + }) }) it('can map failures to a different error type via wrap', async () => { @@ -195,14 +191,12 @@ describe('handlerGen', () => { }) it('runs wrap with effect/context so wrap and handler share services and tracer', async () => { - const spans: { name: string, parent: string | undefined }[] = [] + const spans: Tracer.Span[] = [] const tracer = Tracer.make({ span(options) { - spans.push({ - name: options.name, - parent: Option.getOrUndefined(Option.map(options.parent, parent => parent._tag === 'Span' ? parent.name : undefined)), - }) - return Tracer.nativeTracer.span(options) + const span = Tracer.nativeTracer.span(options) + spans.push(span) + return span }, }) @@ -229,10 +223,8 @@ describe('handlerGen', () => { })).resolves.toEqual('output:Service1') expect(serviceInWrap).toEqual(Option.some({ id: 'Service1' })) - expect(spans).toEqual([ - { name: 'wrap', parent: undefined }, - { name: 'handler', parent: 'wrap' }, - ]) + 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 () => { @@ -251,37 +243,6 @@ describe('handlerGen', () => { })).resolves.toEqual('output:fromWrap') }) - it('runs with the scheduler supplied via effect/context', async () => { - const scheduleTask = vi.fn() - - class TrackedScheduler extends Scheduler.MixedScheduler { - override makeDispatcher() { - const dispatcher = super.makeDispatcher() - const original = dispatcher.scheduleTask.bind(dispatcher) - dispatcher.scheduleTask = (task, priority) => { - scheduleTask() - original(task, priority) - } - return dispatcher - } - } - - const procedure = os - .$context>() - .handler(handlerGen(function* () { - yield* Effect.yieldNow - return 'output' - })) - - await expect(call(procedure, undefined, { - context: { - 'effect/context': Context.empty().pipe(Context.add(Scheduler.Scheduler, new TrackedScheduler('sync'))), - }, - })).resolves.toEqual('output') - - expect(scheduleTask).toHaveBeenCalled() - }) - it('throws ORPCError failures from wrap as-is', async () => { const error = new ORPCError('__TEST__') diff --git a/packages/effect/src/middleware.test.ts b/packages/effect/src/middleware.test.ts index 9406a7f7a7..a93308e306 100644 --- a/packages/effect/src/middleware.test.ts +++ b/packages/effect/src/middleware.test.ts @@ -1,6 +1,6 @@ import type { EffectWrapOptions, WithEffectContext } from './context' import { call, ORPCError, os } from '@orpc/server' -import { Context, Effect, Option, Tracer } from 'effect' +import { Context, Effect } from 'effect' import { z } from 'zod' import { handlerGen } from './handler' import { middlewareGen } from './middleware' @@ -123,11 +123,7 @@ describe('middlewareGen', () => { }) it('can wrap effect execution via effect/wrap', async () => { - const wrapOptions: EffectWrapOptions[] = [] - const wrap: WithEffectContext['effect/wrap'] = (effect, opts) => { - wrapOptions.push(opts) - return effect - } + const wrap = vi.fn((effect: any, _opts: EffectWrapOptions) => effect) const procedure = os .$context>() @@ -147,7 +143,7 @@ describe('middlewareGen', () => { { path: ['wrapped', 'procedure'], context, signal, lastEventId: 'id' }, )).resolves.toEqual('output') - expect(wrapOptions).toEqual([{ + expect(wrap).toHaveBeenCalledExactlyOnceWith(expect.anything(), { kind: 'middleware', context, // validated input of the middleware rather than the raw procedure input @@ -157,16 +153,12 @@ describe('middlewareGen', () => { signal, lastEventId: 'id', errors: { BASE: expect.any(Function) }, - }]) - expect(wrapOptions[0]).not.toHaveProperty('next') + }) }) it('invokes effect/wrap once per middlewareGen and handlerGen', async () => { - const wrapOptions: EffectWrapOptions[] = [] - const wrap: WithEffectContext['effect/wrap'] = (effect, opts) => { - wrapOptions.push(opts) - return effect - } + const wrap = vi.fn((effect: any, _opts: EffectWrapOptions) => effect) + const context = { 'effect/context': Context.empty(), 'effect/wrap': wrap } const procedure = os .$context>() @@ -181,14 +173,12 @@ describe('middlewareGen', () => { return 'output' })) - await expect(call(procedure, undefined, { - context: { 'effect/context': Context.empty(), 'effect/wrap': wrap }, - })).resolves.toEqual('output') + await expect(call(procedure, undefined, { context })).resolves.toEqual('output') - expect(wrapOptions.map(opts => [opts.kind, Object.keys(opts.context).filter(key => !key.startsWith('effect/'))])).toEqual([ - ['middleware', []], - ['middleware', ['first', 'plain']], - ['handler', ['first', 'plain', 'second']], + expect(wrap.mock.calls.map(([, opts]) => [opts.kind, opts.context])).toEqual([ + ['middleware', context], + ['middleware', { ...context, first: true, plain: true }], + ['handler', { ...context, first: true, plain: true, second: true }], ]) }) @@ -232,74 +222,24 @@ describe('middlewareGen', () => { )).rejects.toSatisfy( error => error instanceof ORPCError && error.message === 'middleware' && error.cause === downstreamError, ) - - // already mapped by the handler wrap, passes through the middleware wrap as-is - await expect(call( - os - .$context>() - .use(middlewareGen(function* ({ next }) { - return yield* next() - })) - .handler(handlerGen(function* () { - return yield* Effect.fail(downstreamError) - })), - undefined, - { context }, - )).rejects.toSatisfy( - error => error instanceof ORPCError && error.message === 'handler' && error.cause === downstreamError, - ) - }) - - it('runs effect/wrap with effect/context so wrap and middleware share services and tracer', async () => { - const spans: string[] = [] - const tracer = Tracer.make({ - span(options) { - spans.push(options.name) - return Tracer.nativeTracer.span(options) - }, - }) - - let serviceInWrap: Option.Option | undefined - - const procedure = os - .$context>() - .use(middlewareGen(function* ({ next }) { - const service1 = yield* Service1 - return yield* next({ context: { fromService: service1.id } }).pipe(Effect.withSpan('middleware')) - })) - .handler(({ context }) => `output:${context.fromService}`) - - 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:Service1') - - expect(serviceInWrap).toEqual(Option.some({ id: 'Service1' })) - expect(spans).toEqual(['wrap', 'middleware']) }) - it('forwards the provided signal to runPromise', async () => { + it('forwards effect/context and the provided signal to runPromise', async () => { const controller = new AbortController() + const effectContext = Context.empty().pipe(Context.add(Service1, { id: 'Service1' })) const procedure = os + .$context>() .use(middlewareGen(function* ({ next }) { return yield* next() })) .handler(() => 'output') await expect( - call(procedure, undefined, { 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: undefined, 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 9bd4d2dc8d..6fd0a8b3b0 100644 --- a/packages/effect/src/middleware.ts +++ b/packages/effect/src/middleware.ts @@ -2,7 +2,6 @@ import type { ErrorMap } from '@orpc/contract' 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 { omit } from '@orpc/shared' import { Effect, Context as EffectContext } from 'effect' import { runPromise } from './runtime' @@ -69,10 +68,16 @@ export function middlewareGen< const wrap = opts.context['effect/wrap'] as WithEffectContext['effect/wrap'] const ef = Effect.gen(() => middleware({ ...opts, next }, input, done)) as Effect.Effect, unknown> - return runPromise( - typeof wrap === 'function' ? wrap(ef, { ...omit(opts, ['next']), kind: 'middleware', input }) : ef, - { context: EffectContext.isContext(effectContext) ? effectContext : undefined, signal: opts.signal }, - ) + let wrapped = ef + if (typeof wrap === 'function') { + const { next: _next, ...rest } = opts + wrapped = wrap(ef, { ...rest, kind: 'middleware', input }) + } + + return runPromise(wrapped, { + context: EffectContext.isContext(effectContext) ? effectContext : undefined, + signal: opts.signal, + }) } Object.defineProperty(mid, 'name', { value: middleware.name }) diff --git a/packages/effect/src/runtime.test.ts b/packages/effect/src/runtime.test.ts index 4b74e99b83..68230580b0 100644 --- a/packages/effect/src/runtime.test.ts +++ b/packages/effect/src/runtime.test.ts @@ -1,6 +1,6 @@ import { AbortError } from '@orpc/shared' -import { Cause, Effect } from 'effect' -import { describe, expect, it } from 'vitest' +import { Cause, Context, Effect, Option, Scheduler } from 'effect' +import { describe, expect, it, vi } from 'vitest' import { runPromise } from './runtime' describe('runPromise & extractErrorFromCause', () => { @@ -17,6 +17,25 @@ describe('runPromise & extractErrorFromCause', () => { }) }) + describe('context', () => { + class Service1 extends Context.Service()('Service1') {} + + 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' })) + }) + + it('runs the effect with the provided scheduler', async () => { + const scheduler = new Scheduler.MixedScheduler('sync') + const makeDispatcher = vi.spyOn(scheduler, 'makeDispatcher') + const context = Context.empty().pipe(Context.add(Scheduler.Scheduler, scheduler)) + + await expect(runPromise(Effect.yieldNow.pipe(Effect.as(42)), { context })).resolves.toBe(42) + expect(makeDispatcher).toHaveBeenCalled() + }) + }) + describe('failure - throws original error without FiberFailure wrapper', () => { it('throws signal reason when interrupted with signal', async () => { const signal = AbortSignal.timeout(0) diff --git a/packages/effect/src/runtime.ts b/packages/effect/src/runtime.ts index 8c82580ffd..ccc2265a8b 100644 --- a/packages/effect/src/runtime.ts +++ b/packages/effect/src/runtime.ts @@ -1,6 +1,5 @@ -import type { Context } from 'effect' import { AbortError } from '@orpc/shared' -import { Cause, Effect, Exit } from 'effect' +import { Cause, Context, Effect, Exit } from 'effect' export interface RunPromiseOptions extends Effect.RunOptions { /** @@ -13,11 +12,7 @@ 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 { - const { context, ...runOptions } = options - - const exit = context - ? await Effect.runPromiseExitWith(context)(effect, runOptions) - : await Effect.runPromiseExit(effect, runOptions) + const exit = await Effect.runPromiseExitWith(options.context ?? Context.empty())(effect, options) if (Exit.isSuccess(exit)) { return exit.value From 624495e0d20ea5d09a20bec3cce7082d13f46cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 08:38:55 +0000 Subject: [PATCH 3/6] test(effect): dedupe effect/wrap and runtime tests - handler: drop the plain-error passthrough test (covered by the first test and runtime.test.ts), fold "throws ORPCError failures from wrap as-is" into the mapError test, and stop the tracer test re-checking handler services. - middleware: keep only the downstream case of the error-mapping test; a middleware's own failure repeats the handler mapError test. - runtime: merge the two "throws the original Error from Effect.fail" tests. - types: drop the wrap cases in handler/middleware test-d that repeat context.test-d, and the single-branch catchCause / fail cases already type-checked by the runtime tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BQ43QtcK3ZzzFbbxjAGFeg --- packages/effect/src/context.test-d.ts | 8 ---- packages/effect/src/handler.test-d.ts | 30 +------------- packages/effect/src/handler.test.ts | 48 ++++------------------ packages/effect/src/middleware.test-d.ts | 28 +------------ packages/effect/src/middleware.test.ts | 52 ++++++++---------------- packages/effect/src/runtime.test.ts | 9 +--- 6 files changed, 28 insertions(+), 147 deletions(-) diff --git a/packages/effect/src/context.test-d.ts b/packages/effect/src/context.test-d.ts index cc761972be..7c9b683011 100644 --- a/packages/effect/src/context.test-d.ts +++ b/packages/effect/src/context.test-d.ts @@ -32,10 +32,6 @@ describe('WithEffectContext', () => { Effect.mapError(error => new ORPCError('BAD_REQUEST', { cause: error })), ) - const catchCause: Wrap = effect => effect.pipe( - Effect.catchCause(cause => Effect.fail(new ORPCError('INTERNAL_SERVER_ERROR', { cause: Cause.squash(cause) }))), - ) - const mapSome: Wrap = effect => effect.pipe( Effect.catchCause((cause) => { const error = Cause.squash(cause) @@ -48,12 +44,8 @@ describe('WithEffectContext', () => { }), ) - const fail: Wrap = () => Effect.fail(new ORPCError('UNAUTHORIZED')) - void mapError - void catchCause void mapSome - void fail // @ts-expect-error - must keep the success type const invalidSuccess: Wrap = effect => effect.pipe(Effect.as('invalid')) diff --git a/packages/effect/src/handler.test-d.ts b/packages/effect/src/handler.test-d.ts index fec129f259..12f18e1b01 100644 --- a/packages/effect/src/handler.test-d.ts +++ b/packages/effect/src/handler.test-d.ts @@ -1,7 +1,7 @@ import type { InitialInputSchema, Schema } from '@orpc/contract' import type { DecoratedProcedure, DefaultInitialContext, ORPCErrorConstructorMap } from '@orpc/server' import type { WithEffectContext } from './context' -import { call, ORPCError, os } from '@orpc/server' +import { ORPCError, os } from '@orpc/server' import { Context, Effect } from 'effect' import { z } from 'zod' import { handlerGen } from './handler' @@ -108,32 +108,4 @@ describe('handlerGen', () => { yield* Service2 })) }) - - it('accepts effect/wrap that changes the error type', async () => { - const procedure = os - .$context>() - .handler(handlerGen(function* () { - const service1 = yield* Service1 - return service1.id - })) - - const effectContext = Context.empty().pipe(Context.add(Service1, { id: 'Service1' })) - - await call(procedure, undefined, { - context: { - 'effect/context': effectContext, - 'effect/wrap': effect => effect.pipe( - Effect.mapError(error => new ORPCError('BAD_REQUEST', { cause: error })), - ), - }, - }) - - await call(procedure, undefined, { - context: { - 'effect/context': effectContext, - // @ts-expect-error - wrap must keep the success type - 'effect/wrap': effect => effect.pipe(Effect.as('invalid')), - }, - }) - }) }) diff --git a/packages/effect/src/handler.test.ts b/packages/effect/src/handler.test.ts index f270b93835..c3d4648ee7 100644 --- a/packages/effect/src/handler.test.ts +++ b/packages/effect/src/handler.test.ts @@ -43,17 +43,6 @@ describe('handlerGen', () => { expect(error.defined).toBe(false) }) - it('throw original errors without fiber failure error wrapper', async () => { - const error = new Error('__TEST__') - await expect( - call(os.handler(handlerGen(function* () { - yield* Effect.fail(error) - }))), - ).rejects.toBe(error) - - expect(runPromiseSpy).toHaveBeenCalledTimes(1) - }) - it('forwards the provided signal to runPromise', async () => { const controller = new AbortController() @@ -148,8 +137,9 @@ describe('handlerGen', () => { }) }) - it('can map failures to a different error type via wrap', async () => { + it('can map failures to a different error type via wrap, and throws it as-is', async () => { const domainError = new Error('__DOMAIN__') + const error = new ORPCError('BAD_REQUEST', { cause: domainError }) const procedure = os .$context>() @@ -161,12 +151,11 @@ describe('handlerGen', () => { context: { 'effect/context': Context.empty(), 'effect/wrap': effect => effect.pipe( - Effect.mapError(error => new ORPCError('BAD_REQUEST', { cause: error })), + Effect.mapError(cause => cause === domainError ? error : cause), ), }, - })).rejects.toSatisfy( - error => error instanceof ORPCError && error.code === 'BAD_REQUEST' && error.cause === domainError, - ) + })).rejects.toBe(error) + expect(error.defined).toBe(false) }) it('can convert defects to ORPCError via wrap with catchCause', async () => { @@ -205,8 +194,7 @@ describe('handlerGen', () => { const procedure = os .$context>() .handler(handlerGen(function* () { - const service1 = yield* Service1 - return yield* Effect.succeed(`output:${service1.id}`).pipe(Effect.withSpan('handler')) + return yield* Effect.succeed('output').pipe(Effect.withSpan('handler')) })) await expect(call(procedure, undefined, { @@ -220,7 +208,7 @@ describe('handlerGen', () => { return yield* effect }).pipe(Effect.withSpan('wrap')), }, - })).resolves.toEqual('output:Service1') + })).resolves.toEqual('output') expect(serviceInWrap).toEqual(Option.some({ id: 'Service1' })) expect(spans.map(span => span.name)).toEqual(['wrap', 'handler']) @@ -242,26 +230,4 @@ describe('handlerGen', () => { }, })).resolves.toEqual('output:fromWrap') }) - - it('throws ORPCError failures from wrap as-is', async () => { - const error = new ORPCError('__TEST__') - - const procedure = os - .$context>() - .handler(handlerGen(function* () { - return 'output' - })) - - await expect(call( - procedure, - undefined, - { - context: { - 'effect/context': Context.empty(), - 'effect/wrap': () => Effect.fail(error), - }, - }, - )).rejects.toBe(error) - expect(error.defined).toBe(false) - }) }) diff --git a/packages/effect/src/middleware.test-d.ts b/packages/effect/src/middleware.test-d.ts index 47d2403cee..105d14f0e6 100644 --- a/packages/effect/src/middleware.test-d.ts +++ b/packages/effect/src/middleware.test-d.ts @@ -1,6 +1,6 @@ import type { ORPCErrorConstructorMap } from '@orpc/server' import type { WithEffectContext } from './context' -import { call, ORPCError, os } from '@orpc/server' +import { os } from '@orpc/server' import { Context, Effect } from 'effect' import { z } from 'zod' import { middlewareGen } from './middleware' @@ -184,30 +184,4 @@ describe('middlewareGen', () => { return yield* next() })) }) - - it('accepts effect/wrap that changes the error type and narrows by kind', async () => { - const procedure = os - .$context>() - .use(middlewareGen(function* ({ next }) { - yield* Service1 - return yield* next() - })) - .handler(() => 'output') - - await call(procedure, undefined, { - context: { - 'effect/context': Context.empty().pipe(Context.add(Service1, { id: 'Service1' })), - 'effect/wrap': (effect, opts) => { - if (opts.kind === 'middleware') { - expectTypeOf(opts.input).toEqualTypeOf() - expectTypeOf(opts).not.toHaveProperty('next') - } - - return effect.pipe( - Effect.mapError(error => new ORPCError('BAD_REQUEST', { cause: error })), - ) - }, - }, - }) - }) }) diff --git a/packages/effect/src/middleware.test.ts b/packages/effect/src/middleware.test.ts index a93308e306..786c5932d0 100644 --- a/packages/effect/src/middleware.test.ts +++ b/packages/effect/src/middleware.test.ts @@ -182,44 +182,26 @@ describe('middlewareGen', () => { ]) }) - it('can map own and downstream failures to a different error type via effect/wrap', async () => { - const middlewareError = new Error('__MIDDLEWARE__') + it('passes downstream failures through effect/wrap', async () => { const downstreamError = new Error('__DOWNSTREAM__') - const context: WithEffectContext = { - 'effect/context': Context.empty(), - 'effect/wrap': (effect, opts) => effect.pipe( - Effect.mapError(error => error instanceof ORPCError - ? error - : new ORPCError('BAD_REQUEST', { message: opts.kind, cause: error })), - ), - } - - await expect(call( - os - .$context>() - .use(middlewareGen(function* () { - return yield* Effect.fail(middlewareError) - })) - .handler(() => 'unreachable'), - undefined, - { context }, - )).rejects.toSatisfy( - error => error instanceof ORPCError && error.code === 'BAD_REQUEST' && error.cause === middlewareError, - ) + const procedure = os + .$context>() + .use(middlewareGen(function* ({ next }) { + return yield* next() + })) + .handler(() => { + throw downstreamError + }) - await expect(call( - os - .$context>() - .use(middlewareGen(function* ({ next }) { - return yield* next() - })) - .handler(() => { - throw downstreamError - }), - undefined, - { context }, - )).rejects.toSatisfy( + 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, ) }) diff --git a/packages/effect/src/runtime.test.ts b/packages/effect/src/runtime.test.ts index 68230580b0..384b8adbd3 100644 --- a/packages/effect/src/runtime.test.ts +++ b/packages/effect/src/runtime.test.ts @@ -52,11 +52,6 @@ describe('runPromise & extractErrorFromCause', () => { await expect(runPromise(effect)).rejects.toThrow(new AbortError('All fibers interrupted without error')) }) - it('throws the original Error instance from Effect.fail', async () => { - const original = new TypeError('typed domain error') - await expect(runPromise(Effect.fail(original))).rejects.toThrow(original) - }) - it('throws the original defect from Effect.die', async () => { const defect = new RangeError('unexpected defect') await expect(runPromise(Effect.die(defect))).rejects.toThrow(defect) @@ -95,8 +90,8 @@ describe('runPromise & extractErrorFromCause', () => { await expect(runPromise(effect)).rejects.toThrow(leftError) }) - it('does NOT wrap errors in FiberFailure', async () => { - const original = new TypeError('original') + it('throws the original Error instance from Effect.fail, not a FiberFailure', async () => { + const original = new TypeError('typed domain error') await expect(runPromise(Effect.fail(original))).rejects.toBe(original) }) From 1fa3955c1fb50cafe02f29f542528f4b84f50a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:02:47 +0000 Subject: [PATCH 4/6] docs(effect): stop suggesting Effect.withSpan in effect/wrap The oRPC OpenTelemetry integration already creates spans for every middleware and handler, so a span in the wrap only duplicates them. Present the wrap as an error-handling hook in its JSDoc and example. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BQ43QtcK3ZzzFbbxjAGFeg --- apps/content/docs/integrations/effect.mdx | 2 +- packages/effect/src/context.test-d.ts | 2 +- packages/effect/src/context.ts | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/content/docs/integrations/effect.mdx b/apps/content/docs/integrations/effect.mdx index 525323bb17..4a29f47e1f 100644 --- a/apps/content/docs/integrations/effect.mdx +++ b/apps/content/docs/integrations/effect.mdx @@ -371,7 +371,7 @@ const converter = new EffectSchemaToJsonSchemaConverter({ cache: true }) ## OpenTelemetry Integration -First, set up the [oRPC OpenTelemetry integration](/docs/integrations/opentelemetry). Then install [`@effect/opentelemetry`](https://www.npmjs.com/package/@effect/opentelemetry) and provide its tracer through `effect/context`. This makes Effect tracing equivalent to OpenTelemetry tracing, both in handlers and in `effect/wrap`: +First, set up the [oRPC OpenTelemetry integration](/docs/integrations/opentelemetry). Then install [`@effect/opentelemetry`](https://www.npmjs.com/package/@effect/opentelemetry) and provide its tracer through `effect/context`. This makes Effect tracing equivalent to OpenTelemetry tracing: ```ts import { OtelTracer } from '@effect/opentelemetry' diff --git a/packages/effect/src/context.test-d.ts b/packages/effect/src/context.test-d.ts index 7c9b683011..5cc928ffb0 100644 --- a/packages/effect/src/context.test-d.ts +++ b/packages/effect/src/context.test-d.ts @@ -85,7 +85,7 @@ describe('WithEffectContext', () => { expectTypeOf(opts).toEqualTypeOf() } - return effect.pipe(Effect.withSpan(`${opts.kind}:${opts.path.join('.')}`)) + return effect } void wrap diff --git a/packages/effect/src/context.ts b/packages/effect/src/context.ts index 8b3cff7987..d7343f855d 100644 --- a/packages/effect/src/context.ts +++ b/packages/effect/src/context.ts @@ -60,7 +60,7 @@ export interface WithEffectContext { /** * An optional hook to wrap an effect before it is executed within this oRPC context. - * Useful for adding observability, tracing, or error handling. + * Useful for customizing error handling, such as mapping errors to `ORPCError`. * * - Runs once per `middlewareGen` and once per `handlerGen` of a request, * each with its own effect. Use `opts.kind` to tell them apart, and keep the wrap @@ -84,11 +84,10 @@ export interface WithEffectContext { * * const context: ServerContext = { * 'effect/context': Context.empty(), - * 'effect/wrap': (effect, opts) => effect.pipe( + * 'effect/wrap': effect => effect.pipe( * Effect.mapError(error => error instanceof NotFoundError * ? new ORPCError('NOT_FOUND', { cause: error }) * : error), - * Effect.withSpan(`${opts.kind}:${opts.path.join('.')}`), * ), * } * ``` From d4a0aa25169100317e335a671a538fe2239f8abb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:13:27 +0000 Subject: [PATCH 5/6] docs(effect): shorten effect/wrap and effect/context docs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BQ43QtcK3ZzzFbbxjAGFeg --- apps/content/docs/integrations/effect.mdx | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/content/docs/integrations/effect.mdx b/apps/content/docs/integrations/effect.mdx index 4a29f47e1f..c1497801c3 100644 --- a/apps/content/docs/integrations/effect.mdx +++ b/apps/content/docs/integrations/effect.mdx @@ -116,21 +116,20 @@ const procedure = os ::: -`effect/context` also accepts Effect references such as `Tracer`, `Logger` or `Scheduler`, which then apply to every effect that runs under this context. For example, Effect recommends a sync scheduler on Cloudflare Workers: +:::tip +`effect/context` also accepts references like `Tracer`, `Logger` or `Scheduler`, e.g. a sync scheduler on Cloudflare Workers: ```ts -import { Context, Scheduler } from 'effect' - -const effectContext = Context.empty().pipe( - Context.add(Scheduler.Scheduler, new Scheduler.MixedScheduler('sync')), -) +Context.add(Scheduler.Scheduler, new Scheduler.MixedScheduler('sync')) ``` +::: + ### Error Handling This integration preserves the original error whenever possible. If you call `Effect.fail(error)`, the error is forwarded to [middleware](/docs/middleware) and interceptors, just like a regular thrown error. -To customize this behavior, wrap the effect before execution using `effect/wrap` in the context. The wrap may change the error type, for example to turn domain errors into `ORPCError`: +To customize this behavior, wrap the effect before execution using `effect/wrap` in the context: ```ts twoslash import type { WithEffectContext } from '@orpc/experimental-effect' @@ -152,7 +151,6 @@ export async function fetch(request: Request) { context: { 'effect/context': Context.empty(), 'effect/wrap': effect => effect.pipe( - // also catches defects, such as errors thrown inside the generator Effect.catchCause((cause) => { const error = Cause.squash(cause) @@ -163,7 +161,6 @@ export async function fetch(request: Request) { })) } - // keep everything else, including interruptions, as-is return Effect.failCause(cause) }), ), @@ -178,9 +175,7 @@ export async function fetch(request: Request) { } ``` -The wrap runs once for every `handlerGen` and `middlewareGen` in a request, each with its own effect. `opts.kind` (`'handler'` or `'middleware'`) tells them apart, and `opts` also holds that layer's `context`, `input`, `path`, `procedure`, `errors` and `signal`. Failures from inner layers reach outer `middlewareGen`s through `next`, so they pass through the wrap again. Keep the wrap idempotent, as the example above is: an `ORPCError` that is already mapped passes through unchanged. - -The wrapped effect runs with `effect/context`, so code in the wrap sees the same services, `Tracer` and `Logger` as the handler. Services the wrap provides take precedence over `effect/context` for the effect it wraps. +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. @@ -371,7 +366,7 @@ const converter = new EffectSchemaToJsonSchemaConverter({ cache: true }) ## OpenTelemetry Integration -First, set up the [oRPC OpenTelemetry integration](/docs/integrations/opentelemetry). Then install [`@effect/opentelemetry`](https://www.npmjs.com/package/@effect/opentelemetry) and provide its tracer through `effect/context`. This makes Effect tracing equivalent to OpenTelemetry tracing: +First, set up the [oRPC OpenTelemetry integration](/docs/integrations/opentelemetry). Then provide the [`@effect/opentelemetry`](https://www.npmjs.com/package/@effect/opentelemetry) tracer through `effect/context`: ```ts import { OtelTracer } from '@effect/opentelemetry' @@ -379,7 +374,6 @@ import { ManagedRuntime } from 'effect' interface ServerContext extends WithEffectContext {} -// Build the Effect tracer, backed by the global OpenTelemetry tracer provider, once const effectContext = await ManagedRuntime.make(OtelTracer.layerGlobal).context() export async function fetch(request: Request) { From 1c57cd3023faa18f06d3b8aa840c0c8c5696d48e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:14:43 +0000 Subject: [PATCH 6/6] docs(effect): shorten effect/wrap JSDoc Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BQ43QtcK3ZzzFbbxjAGFeg --- packages/effect/src/context.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/effect/src/context.ts b/packages/effect/src/context.ts index d7343f855d..dd45abfb89 100644 --- a/packages/effect/src/context.ts +++ b/packages/effect/src/context.ts @@ -9,7 +9,7 @@ import type { Effect, Context as EffectContext } from 'effect' export interface EffectWrapMiddlewareOptions extends Omit>, 'next'> { kind: 'middleware' /** - * The input the middleware receives, validated by the input schemas defined before it. + * The middleware's input, validated by the schemas defined before it. */ input: unknown } @@ -62,14 +62,8 @@ export interface WithEffectContext { * 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 once per `middlewareGen` and once per `handlerGen` of a request, - * each with its own effect. Use `opts.kind` to tell them apart, and keep the wrap - * idempotent: failures of inner layers reach outer `middlewareGen`s through `next`, - * so they pass through the wrap again. - * - The wrapped effect runs with `effect/context`, so the wrap sees the same services - * (`Tracer`, `Logger`, ...) as the handler. Services the wrap provides take precedence - * over `effect/context` for the effect it wraps. - * - The returned effect may fail with any error, which is thrown as-is. + * Runs with `effect/context`, once per `middlewareGen` and `handlerGen` (see `opts.kind`). + * 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} *