effect-orpc 1.0.0-effect-v4.2 → 1.0.0-effect-v4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,495 @@
1
+ # effect-orpc
2
+
3
+ A type-safe integration between [oRPC](https://orpc.dev/) and [Effect](https://effect.website/), enabling Effect-native procedures with full service injection support, OpenTelemetry tracing support and typesafe Effect errors support.
4
+
5
+ Inspired by [effect-trpc](https://github.com/mikearnaldi/effect-trpc).
6
+
7
+ ## Features
8
+
9
+ - **Effect-native procedures** - Write oRPC procedures using generators with `yield*` syntax
10
+ - **Type-safe service injection** - Use `ManagedRuntime<R>` to provide services to procedures with compile-time safety
11
+ - **Tagged errors** - Create Effect-native error classes with `ORPCTaggedError` that integrate with oRPC's error handling
12
+ - **Full oRPC compatibility** - Mix Effect procedures with standard oRPC procedures in the same router
13
+ - **Telemetry support with automatic tracing** - Procedures are automatically traced with OpenTelemetry-compatible spans. Customize span names with `.traced()`.
14
+ - **Builder pattern preserved** - oRPC builder methods (`.errors()`, `.meta()`, `.route()`, `.input()`, `.output()`, `.use()`) work seamlessly
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install effect-orpc
20
+ # or
21
+ pnpm add effect-orpc
22
+ # or
23
+ bun add effect-orpc
24
+ ```
25
+
26
+ Runnable demos live in the repository's `examples/` directory.
27
+
28
+ ## Demo
29
+
30
+ ```ts
31
+ import { os } from "@orpc/server";
32
+ import { Effect, Layer, ManagedRuntime, Context } from "effect";
33
+ import { makeEffectORPC, ORPCTaggedError } from "effect-orpc";
34
+
35
+ interface User {
36
+ id: number;
37
+ name: string;
38
+ }
39
+
40
+ let users: User[] = [
41
+ { id: 1, name: "John Doe" },
42
+ { id: 2, name: "Jane Doe" },
43
+ { id: 3, name: "James Dane" },
44
+ ];
45
+
46
+ // Authenticated os with initial context & errors set
47
+ const authedOs = os
48
+ .errors({ UNAUTHORIZED: { status: 401 } })
49
+ .$context<{ userId?: number }>()
50
+ .use(({ context, errors, next }) => {
51
+ if (context.userId === undefined) throw errors.UNAUTHORIZED();
52
+ return next({ context: { ...context, userId: context.userId } });
53
+ });
54
+
55
+ // Define your services
56
+ class UsersRepo extends Context.Service<
57
+ UsersRepo,
58
+ {
59
+ readonly get: (id: number) => User | undefined;
60
+ }
61
+ >()("UsersRepo") {
62
+ static readonly layer = Layer.succeed(this, {
63
+ get: (id: number) => users.find((u) => u.id === id),
64
+ });
65
+ }
66
+
67
+ // Special yieldable oRPC error class
68
+ class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
69
+ status: 404,
70
+ }) {}
71
+
72
+ // Create runtime with your services
73
+ const runtime = ManagedRuntime.make(UsersRepo.layer);
74
+ // Create Effect-aware oRPC builder from an other (optional) base oRPC builder and provide tagged errors
75
+ const effectOs = makeEffectORPC(runtime, authedOs).errors({
76
+ UserNotFoundError,
77
+ });
78
+
79
+ // Create the router with mixed procedures
80
+ export const router = {
81
+ health: os.handler(() => "ok"),
82
+ users: {
83
+ me: effectOs.effect(function* ({ context: { userId } }) {
84
+ const usersRepo = yield* UsersRepo;
85
+ const user = usersRepo.get(userId);
86
+ if (!user) {
87
+ return yield* new UserNotFoundError();
88
+ }
89
+ return user;
90
+ }),
91
+ },
92
+ };
93
+
94
+ export type Router = typeof router;
95
+ ```
96
+
97
+ ## Type Safety
98
+
99
+ The wrapper enforces that Effect procedures only use services provided by the `ManagedRuntime`. If you try to use a service that isn't in the runtime, you'll get a compile-time error:
100
+
101
+ ```ts
102
+ import { Effect, Layer, ManagedRuntime, Context } from "effect";
103
+ import { makeEffectORPC } from "effect-orpc";
104
+
105
+ class ProvidedService extends Context.Service<
106
+ ProvidedService,
107
+ {
108
+ readonly doSomething: () => Effect.Effect<string>;
109
+ }
110
+ >()("ProvidedService") {}
111
+
112
+ class MissingService extends Context.Service<
113
+ MissingService,
114
+ {
115
+ readonly doSomething: () => Effect.Effect<string>;
116
+ }
117
+ >()("MissingService") {}
118
+
119
+ const runtime = ManagedRuntime.make(
120
+ Layer.succeed(ProvidedService, {
121
+ doSomething: () => Effect.succeed("ok"),
122
+ }),
123
+ );
124
+
125
+ const effectOs = makeEffectORPC(runtime);
126
+
127
+ // ✅ This compiles - ProvidedService is in the runtime
128
+ const works = effectOs.effect(function* () {
129
+ const service = yield* ProvidedService;
130
+ return yield* service.doSomething();
131
+ });
132
+
133
+ // ❌ This fails to compile - MissingService is not in the runtime
134
+ const fails = effectOs.effect(function* () {
135
+ const service = yield* MissingService; // Type error!
136
+ return yield* service.doSomething();
137
+ });
138
+ ```
139
+
140
+ ## Error Handling
141
+
142
+ `ORPCTaggedError` lets you create Effect-native error classes that integrate seamlessly with oRPC. These errors:
143
+
144
+ - Can be yielded in Effect generators (`yield* new MyError()` or `yield* Effect.fail(errors.MyError)`)
145
+ - Can be used in Effect builder's `.errors()` maps for type-safe error handling alongside regular oRPC errors
146
+ - Automatically convert to ORPCError when thrown
147
+
148
+ Make sure the tagged error class is passed to the effect `.errors()` to be able to yield the error class directly and make the client recognize it as defined.
149
+
150
+ ```ts
151
+ const getUser = effectOs
152
+ // Mixed error maps
153
+ .errors({
154
+ // Regular oRPC error
155
+ NOT_FOUND: {
156
+ message: "User not found",
157
+ data: z.object({ id: z.string() }),
158
+ },
159
+ // Effect oRPC tagged error
160
+ UserNotFoundError,
161
+ // Note: The key of an oRPC error is not used as the error code
162
+ // So the following will only change the key of the error when accessing it
163
+ // from the errors object passed to the handler, but not the actual error code itself.
164
+ // To change the error's code, please see the next section on creating tagged errors.
165
+ USER_NOT_FOUND: UserNotFoundError,
166
+ // ^^^ same code as the `UserNotFoundError` error key, defined at the class level
167
+ })
168
+ .effect(function* ({ input, errors }) {
169
+ const user = yield* UsersRepo.findById(input.id);
170
+ if (!user) {
171
+ return yield* new UserNotFoundError();
172
+ // or return `yield* Effect.fail(errors.USER_NOT_FOUND())`
173
+ }
174
+ return user;
175
+ });
176
+ ```
177
+
178
+ ### Creating Tagged Errors
179
+
180
+ ```ts
181
+ import { ORPCTaggedError } from "effect-orpc";
182
+
183
+ // Basic tagged error - code defaults to 'USER_NOT_FOUND' (CONSTANT_CASE of tag)
184
+ class UserNotFound extends ORPCTaggedError("UserNotFound") {}
185
+
186
+ // With explicit code
187
+ class NotFound extends ORPCTaggedError("NotFound", { code: "NOT_FOUND" }) {}
188
+
189
+ // With default options (code defaults to 'VALIDATION_ERROR') (CONSTANT_CASE of tag)
190
+ class ValidationError extends ORPCTaggedError("ValidationError", {
191
+ status: 400,
192
+ message: "Validation failed",
193
+ }) {}
194
+
195
+ // With all options
196
+ class ForbiddenError extends ORPCTaggedError("ForbiddenError", {
197
+ code: "FORBIDDEN",
198
+ status: 403,
199
+ message: "Access denied",
200
+ schema: z.object({
201
+ reason: z.string(),
202
+ }),
203
+ }) {}
204
+
205
+ // With typed data using Standard Schema
206
+ class UserNotFoundWithData extends ORPCTaggedError("UserNotFoundWithData", {
207
+ schema: z.object({ userId: z.string() }),
208
+ }) {}
209
+ ```
210
+
211
+ ## Traceable Spans
212
+
213
+ All Effect procedures are automatically traced with `Effect.withSpan`. By default, the span name is the procedure path (e.g., `users.getUser`):
214
+
215
+ ```ts
216
+ // Router structure determines span names automatically
217
+ const router = {
218
+ users: {
219
+ // Span name: "users.get"
220
+ get: effectOs.input(z.object({ id: z.string() })).effect(function* ({
221
+ input,
222
+ }) {
223
+ const userService = yield* UserService;
224
+ return yield* userService.findById(input.id);
225
+ }),
226
+ // Span name: "users.create"
227
+ create: effectOs.input(z.object({ name: z.string() })).effect(function* ({
228
+ input,
229
+ }) {
230
+ const userService = yield* UserService;
231
+ return yield* userService.create(input.name);
232
+ }),
233
+ },
234
+ };
235
+ ```
236
+
237
+ Use `.traced()` to override the default span name:
238
+
239
+ ```ts
240
+ const getUser = effectOs
241
+ .input(z.object({ id: z.string() }))
242
+ .traced("custom.span.name") // Override the default path-based name
243
+ .effect(function* ({ input }) {
244
+ const userService = yield* UserService;
245
+ return yield* userService.findById(input.id);
246
+ });
247
+ ```
248
+
249
+ ### Enabling OpenTelemetry
250
+
251
+ To enable tracing, include the OpenTelemetry layer in your runtime:
252
+
253
+ ```ts
254
+ import { NodeSdk } from "@effect/opentelemetry";
255
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
256
+ import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
257
+
258
+ const TracingLive = NodeSdk.layer(
259
+ Effect.sync(() => ({
260
+ resource: { serviceName: "my-service" },
261
+ spanProcessor: [new SimpleSpanProcessor(new OTLPTraceExporter())],
262
+ })),
263
+ );
264
+
265
+ const AppLive = Layer.mergeAll(UserServiceLive, TracingLive);
266
+
267
+ const runtime = ManagedRuntime.make(AppLive);
268
+ const effectOs = makeEffectORPC(runtime);
269
+ ```
270
+
271
+ ### Error Stack Traces
272
+
273
+ When an Effect procedure fails, the span includes a properly formatted stack trace pointing to the definition site:
274
+
275
+ ```
276
+ MyCustomError: Something went wrong
277
+ at <anonymous> (/app/src/procedures.ts:42:28)
278
+ at users.getById (/app/src/procedures.ts:41:35)
279
+ ```
280
+
281
+ ## Request-Scoped Fiber Context
282
+
283
+ If you run `effect-orpc` inside a framework such as Hono, the handler executes
284
+ through the runtime boundary and will not automatically inherit request-local
285
+ `FiberRef` state from outer middleware.
286
+
287
+ To preserve request-scoped logs, tracing annotations, and
288
+ other fiber-local state, wrap the framework continuation with `withFiberContext` from
289
+ `effect-orpc/node`.
290
+
291
+ ```ts
292
+ import { Hono } from "hono";
293
+ import { Effect, ManagedRuntime } from "effect";
294
+ import { makeEffectORPC } from "effect-orpc";
295
+ import { withFiberContext } from "effect-orpc/node";
296
+
297
+ const runtime = ManagedRuntime.make(AppLive);
298
+ const effectOs = makeEffectORPC(runtime);
299
+ const app = new Hono();
300
+
301
+ app.use("*", async (c, next) => {
302
+ await Effect.runPromise(
303
+ Effect.gen(function* () {
304
+ yield* Effect.annotateLogsScoped({
305
+ requestId: c.get("requestId"),
306
+ });
307
+
308
+ yield* withFiberContext(() => next());
309
+ }),
310
+ );
311
+ });
312
+ ```
313
+
314
+ When a captured fiber context and the `ManagedRuntime` both provide the same
315
+ service, `effect-orpc` prioritizes the captured context. The runtime is treated
316
+ as the application-wide base layer, while `withFiberContext` preserves the
317
+ more specific request-scoped values from outer middleware. This prevents
318
+ request-local references such as request IDs, logging annotations, tracing
319
+ context, or scoped overrides from being replaced by runtime defaults when the
320
+ handler crosses the runtime boundary.
321
+
322
+ The reason for the separate `/node` entrypoint is that `withFiberContext` relies
323
+ on Node/Bun's `AsyncLocalStorage` from `node:async_hooks` to carry Effect
324
+ `FiberRef` state across framework async boundaries. The main package stays
325
+ runtime-agnostic.
326
+
327
+ If you do not need framework-to-handler fiber propagation, you do not need the
328
+ `/node` entrypoint at all.
329
+
330
+ ## Contract-First Usage
331
+
332
+ Use `implementEffect(contract, runtime)` when you already have an oRPC contract
333
+ and want to keep contract-first enforcement while adding Effect-native handlers.
334
+ Use `makeEffectORPC(runtime, builder?)` when you want to build procedures
335
+ directly from an oRPC builder.
336
+
337
+ ```ts
338
+ import { Effect, ManagedRuntime } from "effect";
339
+ import { eoc, implementEffect } from "effect-orpc";
340
+ import z from "zod";
341
+
342
+ class UsersRepo extends Effect.Service<UsersRepo>()("UsersRepo", {
343
+ accessors: true,
344
+ sync: () => ({
345
+ list: (amount: number) =>
346
+ Array.from({ length: amount }, (_, index) => `user-${index + 1}`),
347
+ }),
348
+ }) {}
349
+
350
+ const contract = {
351
+ users: {
352
+ list: eoc
353
+ .input(z.object({ amount: z.number().int().positive() }))
354
+ .output(z.array(z.string())),
355
+ },
356
+ };
357
+
358
+ const runtime = ManagedRuntime.make(UsersRepo.Default);
359
+ const oe = implementEffect(contract, runtime);
360
+
361
+ export const router = oe.router({
362
+ users: {
363
+ list: oe.users.list.effect(function* ({ input }) {
364
+ return yield* UsersRepo.list(input.amount);
365
+ }),
366
+ },
367
+ });
368
+ ```
369
+
370
+ Contract leaves keep the contract-defined input, output, and error surface.
371
+ They add `.effect(...)` alongside existing implementer methods such as
372
+ `.handler(...)` and `.use(...)`, but do not expose contract-changing builder
373
+ methods like `.input(...)` or `.output(...)`.
374
+
375
+ If your contract declares tagged Effect error classes, prefer `eoc.errors(...)`
376
+ instead of raw `oc.errors(...)` so the error schema and metadata are derived
377
+ directly from the `ORPCTaggedError` class.
378
+
379
+ ## API Reference
380
+
381
+ ### `makeEffectORPC(runtime, builder?)`
382
+
383
+ Creates an Effect-aware procedure builder.
384
+
385
+ - `runtime` - A `ManagedRuntime<R, E>` instance that provides services for Effect procedures
386
+ - `builder` (optional) - An oRPC Builder instance to wrap. Defaults to `os` from `@orpc/server`
387
+
388
+ Returns an `EffectBuilder` instance.
389
+
390
+ ```ts
391
+ // With default builder
392
+ const effectOs = makeEffectORPC(runtime);
393
+
394
+ // With customized builder
395
+ const effectAuthedOs = makeEffectORPC(runtime, authedBuilder);
396
+ ```
397
+
398
+ ### `implementEffect(contract, runtime)`
399
+
400
+ Creates an Effect-aware contract implementer.
401
+
402
+ - `contract` - An oRPC contract router built with `oc`
403
+ - `runtime` - A `ManagedRuntime<R, E>` instance that provides services for Effect procedures
404
+
405
+ Returns a contract-shaped implementer tree whose leaves support `.effect(...)`.
406
+
407
+ ```ts
408
+ const oe = implementEffect(contract, runtime);
409
+
410
+ const router = oe.router({
411
+ users: {
412
+ list: oe.users.list.effect(function* ({ input }) {
413
+ return yield* UsersRepo.list(input.amount);
414
+ }),
415
+ },
416
+ });
417
+ ```
418
+
419
+ ### `eoc`
420
+
421
+ An Effect-aware wrapper around oRPC's `oc` contract builder.
422
+
423
+ Use it when you want contract definitions to accept `ORPCTaggedError` classes
424
+ directly in `.errors(...)` without duplicating the error schema.
425
+
426
+ ```ts
427
+ class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
428
+ code: "NOT_FOUND",
429
+ schema: z.object({ userId: z.string() }),
430
+ }) {}
431
+
432
+ const contract = {
433
+ users: {
434
+ find: eoc
435
+ .errors({
436
+ NOT_FOUND: UserNotFoundError,
437
+ })
438
+ .input(z.object({ userId: z.string() }))
439
+ .output(z.object({ userId: z.string() })),
440
+ },
441
+ };
442
+ ```
443
+
444
+ ### `EffectBuilder`
445
+
446
+ Wraps an oRPC Builder with Effect support. Available methods:
447
+
448
+ | Method | Description |
449
+ | ------------------- | ------------------------------------------------------------------------------- |
450
+ | `.$config(config)` | Set or override the builder config |
451
+ | `.$context<U>()` | Set or override the initial context type |
452
+ | `.$meta(meta)` | Set or override the initial metadata |
453
+ | `.$route(route)` | Set or override the initial route configuration |
454
+ | `.$input(schema)` | Set or override the initial input schema |
455
+ | `.errors(map)` | Add type-safe custom errors |
456
+ | `.meta(meta)` | Set procedure metadata (merged with existing) |
457
+ | `.route(route)` | Configure OpenAPI route (merged with existing) |
458
+ | `.input(schema)` | Define input validation schema |
459
+ | `.output(schema)` | Define output validation schema |
460
+ | `.use(middleware)` | Add middleware |
461
+ | `.traced(name)` | Add a traceable span for telemetry (optional, defaults to the procedure's path) |
462
+ | `.handler(handler)` | Define a non-Effect handler (standard oRPC handler) |
463
+ | `.effect(handler)` | Define the Effect handler |
464
+ | `.prefix(prefix)` | Prefix all procedures in the router (for OpenAPI) |
465
+ | `.tag(...tags)` | Add tags to all procedures in the router (for OpenAPI) |
466
+ | `.router(router)` | Apply all options to a router |
467
+ | `.lazy(loader)` | Create and apply options to a lazy-loaded router |
468
+
469
+ ### `EffectDecoratedProcedure`
470
+
471
+ The result of calling `.effect()`. Extends standard oRPC `DecoratedProcedure` with Effect type preservation.
472
+
473
+ | Method | Description |
474
+ | ----------------------- | --------------------------------------------- |
475
+ | `.errors(map)` | Add more custom errors |
476
+ | `.meta(meta)` | Update metadata (merged with existing) |
477
+ | `.route(route)` | Update route configuration (merged) |
478
+ | `.use(middleware)` | Add middleware |
479
+ | `.callable(options?)` | Make procedure directly invocable |
480
+ | `.actionable(options?)` | Make procedure compatible with server actions |
481
+
482
+ ### `ORPCTaggedError(tag, options?)`
483
+
484
+ Factory function to create Effect-native tagged error classes.
485
+
486
+ The options is an optional object containing:
487
+
488
+ - `schema?` - Optional Standard Schema for the error's data payload (e.g., `z.object({ userId: z.string() })`)
489
+ - `code?` - Optional ORPCErrorCode, defaults to CONSTANT_CASE of the tag (e.g., `UserNotFoundError` → `USER_NOT_FOUND_ERROR`).
490
+ - `status?` - Sets the default status of the error
491
+ - `message` - Sets the default message of the error
492
+
493
+ ## License
494
+
495
+ MIT
@@ -11,4 +11,4 @@ export {
11
11
  installServiceContextBridge,
12
12
  getCurrentServices
13
13
  };
14
- //# sourceMappingURL=chunk-E5YLLTJI.js.map
14
+ //# sourceMappingURL=chunk-I5EWBI42.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/service-context-bridge.ts"],"sourcesContent":["import type { Context } from \"effect\";\n\nexport interface ServiceContextBridge {\n readonly getCurrentServices: () => Context.Context<any> | undefined;\n}\n\nlet bridge: ServiceContextBridge | undefined;\n\nexport function installServiceContextBridge(\n nextBridge: ServiceContextBridge | undefined,\n): void {\n bridge = nextBridge;\n}\n\nexport function getCurrentServices(): Context.Context<any> | undefined {\n return bridge?.getCurrentServices();\n}\n"],"mappings":";AAMA,IAAI;AAEG,SAAS,4BACd,YACM;AACN,WAAS;AACX;AAEO,SAAS,qBAAuD;AACrE,SAAO,QAAQ,mBAAmB;AACpC;","names":[]}