effect-orpc 0.0.1

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,538 @@
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.
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
+ - **Full oRPC compatibility** - Mix Effect procedures with standard oRPC procedures in the same router
12
+ - **Builder pattern preserved** - All oRPC builder methods (`.errors()`, `.meta()`, `.route()`, `.input()`, `.output()`, `.use()`) work seamlessly
13
+ - **Callable procedures** - Make procedures directly invocable while preserving Effect types
14
+ - **Server actions support** - Full compatibility with framework server actions
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
+ ## Quick Start
27
+
28
+ ```ts
29
+ import { makeEffectORPC } from 'effect-orpc'
30
+ import { os } from '@orpc/server'
31
+ import { Context, Effect, Layer, ManagedRuntime } from 'effect'
32
+ import { z } from 'zod'
33
+
34
+ // Define your services
35
+ class UserService extends Context.Tag('UserService')<
36
+ UserService,
37
+ {
38
+ findById: (id: string) => Effect.Effect<User | undefined>
39
+ findAll: () => Effect.Effect<User[]>
40
+ create: (name: string) => Effect.Effect<User>
41
+ }
42
+ >() {}
43
+
44
+ // Create service implementation
45
+ const UserServiceLive = Layer.succeed(UserService, {
46
+ findById: id => Effect.succeed(users.find(u => u.id === id)),
47
+ findAll: () => Effect.succeed(users),
48
+ create: name => Effect.succeed({ id: crypto.randomUUID(), name })
49
+ })
50
+
51
+ // Create runtime with your services
52
+ const runtime = ManagedRuntime.make(UserServiceLive)
53
+
54
+ // Create Effect-aware oRPC builder
55
+ const effectOs = makeEffectORPC(runtime)
56
+
57
+ // Define your procedures
58
+ const getUser = effectOs
59
+ .input(z.object({ id: z.string() }))
60
+ .effect(
61
+ Effect.fn(function* ({ input }) {
62
+ const userService = yield* UserService
63
+ return yield* userService.findById(input.id)
64
+ })
65
+ )
66
+
67
+ const listUsers = effectOs
68
+ .effect(
69
+ Effect.fn(function* () {
70
+ const userService = yield* UserService
71
+ return yield* userService.findAll()
72
+ })
73
+ )
74
+
75
+ const createUser = effectOs
76
+ .input(z.object({ name: z.string() }))
77
+ .effect(
78
+ Effect.fn(function* ({ input }) {
79
+ const userService = yield* UserService
80
+ return yield* userService.create(input.name)
81
+ })
82
+ )
83
+
84
+ // Create router with mixed procedures
85
+ const router = os.router({
86
+ // Standard oRPC procedure
87
+ health: os.handler(() => 'ok'),
88
+
89
+ // Effect procedures
90
+ users: os.router({
91
+ get: getUser,
92
+ list: listUsers,
93
+ create: createUser,
94
+ })
95
+ })
96
+
97
+ export type Router = typeof router
98
+ ```
99
+
100
+ ## Type Safety
101
+
102
+ 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:
103
+
104
+ ```ts
105
+ class ProvidedService extends Context.Tag('ProvidedService')<
106
+ ProvidedService,
107
+ { doSomething: () => Effect.Effect<string> }
108
+ >() {}
109
+
110
+ class MissingService extends Context.Tag('MissingService')<
111
+ MissingService,
112
+ { doSomething: () => Effect.Effect<string> }
113
+ >() {}
114
+
115
+ const runtime = ManagedRuntime.make(Layer.succeed(ProvidedService, {
116
+ doSomething: () => Effect.succeed('ok')
117
+ }))
118
+
119
+ const effectOs = makeEffectORPC(runtime)
120
+
121
+ // ✅ This compiles - ProvidedService is in the runtime
122
+ const works = effectOs
123
+ .effect(
124
+ Effect.fn(function* () {
125
+ const svc = yield* ProvidedService
126
+ return yield* svc.doSomething()
127
+ })
128
+ )
129
+
130
+ // ❌ This fails to compile - MissingService is not in the runtime
131
+ const fails = effectOs
132
+ .effect(
133
+ Effect.fn(function* () {
134
+ const svc = yield* MissingService // Type error!
135
+ return yield* svc.doSomething()
136
+ })
137
+ )
138
+ ```
139
+
140
+ ## Using Services
141
+
142
+ ```ts
143
+ import { makeEffectORPC } from 'effect-orpc'
144
+ import { Context, Effect, Layer, ManagedRuntime } from 'effect'
145
+ import { z } from 'zod'
146
+
147
+ // Define services
148
+ class DatabaseService extends Context.Tag('DatabaseService')<
149
+ DatabaseService,
150
+ {
151
+ query: <T>(sql: string) => Effect.Effect<T[]>
152
+ execute: (sql: string) => Effect.Effect<void>
153
+ }
154
+ >() {}
155
+
156
+ class CacheService extends Context.Tag('CacheService')<
157
+ CacheService,
158
+ {
159
+ get: <T>(key: string) => Effect.Effect<T | undefined>
160
+ set: <T>(key: string, value: T, ttl?: number) => Effect.Effect<void>
161
+ }
162
+ >() {}
163
+
164
+ // Create layers
165
+ const DatabaseServiceLive = Layer.succeed(DatabaseService, {
166
+ query: sql => Effect.succeed([]),
167
+ execute: sql => Effect.succeed(undefined),
168
+ })
169
+
170
+ const CacheServiceLive = Layer.succeed(CacheService, {
171
+ get: key => Effect.succeed(undefined),
172
+ set: (key, value, ttl) => Effect.succeed(undefined),
173
+ })
174
+
175
+ // Compose layers
176
+ const AppLive = Layer.mergeAll(DatabaseServiceLive, CacheServiceLive)
177
+
178
+ // Create runtime with all services
179
+ const runtime = ManagedRuntime.make(AppLive)
180
+ const effectOs = makeEffectORPC(runtime)
181
+
182
+ // Use multiple services in a procedure
183
+ const getUserWithCache = effectOs
184
+ .input(z.object({ id: z.string() }))
185
+ .effect(
186
+ Effect.fn(function* ({ input }) {
187
+ const cache = yield* CacheService
188
+ const db = yield* DatabaseService
189
+
190
+ // Try cache first
191
+ const cached = yield* cache.get<User>(`user:${input.id}`)
192
+ if (cached)
193
+ return cached
194
+
195
+ // Fall back to database
196
+ const [user] = yield* db.query<User>(`SELECT * FROM users WHERE id = '${input.id}'`)
197
+ if (user) {
198
+ yield* cache.set(`user:${input.id}`, user, 3600)
199
+ }
200
+ return user
201
+ })
202
+ )
203
+ ```
204
+
205
+ ## Wrapping a Customized Builder
206
+
207
+ You can pass a customized oRPC builder as the second argument to inherit middleware, errors, and configuration:
208
+
209
+ ```ts
210
+ import { makeEffectORPC } from 'effect-orpc'
211
+ import { ORPCError, os } from '@orpc/server'
212
+ import { Effect } from 'effect'
213
+
214
+ // Create a customized base builder with auth middleware
215
+ const authedOs = os
216
+ .errors({
217
+ UNAUTHORIZED: { message: 'Not authenticated' },
218
+ FORBIDDEN: { message: 'Access denied' },
219
+ })
220
+ .use(async ({ context, next, errors }) => {
221
+ if (!context.user) {
222
+ throw errors.UNAUTHORIZED()
223
+ }
224
+ return next({ context: { ...context, userId: context.user.id } })
225
+ })
226
+
227
+ // Wrap the customized builder with Effect support
228
+ const effectAuthedOs = makeEffectORPC(runtime, authedOs)
229
+
230
+ // All procedures inherit the auth middleware and error definitions
231
+ const getProfile = effectAuthedOs
232
+ .effect(
233
+ Effect.fn(function* ({ context }) {
234
+ const userService = yield* UserService
235
+ return yield* userService.findById(context.userId)
236
+ })
237
+ )
238
+
239
+ const updateProfile = effectAuthedOs
240
+ .input(z.object({ name: z.string() }))
241
+ .effect(
242
+ Effect.fn(function* ({ context, input }) {
243
+ const userService = yield* UserService
244
+ return yield* userService.update(context.userId, input)
245
+ })
246
+ )
247
+ ```
248
+
249
+ ## Chaining Builder Methods
250
+
251
+ The `EffectBuilder` supports all standard oRPC builder methods:
252
+
253
+ ```ts
254
+ const createPost = effectOs
255
+ // Add custom errors
256
+ .errors({
257
+ NOT_FOUND: { message: 'User not found' },
258
+ VALIDATION_ERROR: {
259
+ message: 'Invalid input',
260
+ data: z.object({ field: z.string(), issue: z.string() })
261
+ },
262
+ })
263
+ // Add metadata
264
+ .meta({ auth: true, rateLimit: 100 })
265
+ // Configure route for OpenAPI
266
+ .route({ method: 'POST', path: '/posts', tags: ['posts'] })
267
+ // Define input schema
268
+ .input(z.object({
269
+ title: z.string().min(1).max(200),
270
+ content: z.string(),
271
+ authorId: z.string(),
272
+ }))
273
+ // Define output schema
274
+ .output(z.object({
275
+ id: z.string(),
276
+ title: z.string(),
277
+ content: z.string(),
278
+ createdAt: z.date(),
279
+ }))
280
+ // Define Effect handler
281
+ .effect(({ input, errors }) =>
282
+ Effect.gen(function* () {
283
+ const userService = yield* UserService
284
+ const user = yield* userService.findById(input.authorId)
285
+
286
+ if (!user) {
287
+ throw errors.NOT_FOUND()
288
+ }
289
+
290
+ const postService = yield* PostService
291
+ return yield* postService.create({
292
+ title: input.title,
293
+ content: input.content,
294
+ authorId: input.authorId,
295
+ })
296
+ })
297
+ )
298
+ ```
299
+
300
+ ## Making Procedures Callable
301
+
302
+ Use `.callable()` to make procedures directly invocable:
303
+
304
+ ```ts
305
+ const greet = effectOs
306
+ .input(z.object({ name: z.string() }))
307
+ .effect(({ input }) => Effect.succeed(`Hello, ${input.name}!`))
308
+ .callable()
309
+
310
+ // Can be called directly as a function
311
+ const result = await greet({ name: 'World' })
312
+ // => "Hello, World!"
313
+
314
+ // Still a valid procedure for routers
315
+ const router = os.router({ greet })
316
+ ```
317
+
318
+ ## Server Actions Support
319
+
320
+ Use `.actionable()` for framework server actions (Next.js, etc.):
321
+
322
+ ```tsx
323
+ const createTodo = effectOs
324
+ .input(z.object({ title: z.string() }))
325
+ .effect(
326
+ Effect.fn(function* ({ input }) {
327
+ const todoService = yield* TodoService
328
+ return yield* todoService.create(input.title)
329
+ })
330
+ )
331
+ .actionable({ context: async () => ({ user: await getSession() }) })
332
+
333
+ // Use in React Server Components
334
+ export async function TodoForm() {
335
+ return (
336
+ <form action={createTodo}>
337
+ <input name="title" />
338
+ <button type="submit">Add Todo</button>
339
+ </form>
340
+ )
341
+ }
342
+ ```
343
+
344
+ ## Error Handling
345
+
346
+ Effect errors are properly propagated through oRPC's error handling:
347
+
348
+ ```ts
349
+ import { Effect } from 'effect'
350
+
351
+ class NotFoundError extends Effect.Tag('NotFoundError')<
352
+ NotFoundError,
353
+ { readonly _tag: 'NotFoundError', readonly id: string }
354
+ >() {}
355
+
356
+ const getUser = effectOs
357
+ .errors({
358
+ NOT_FOUND: {
359
+ message: 'User not found',
360
+ data: z.object({ id: z.string() })
361
+ },
362
+ })
363
+ .input(z.object({ id: z.string() }))
364
+ .effect(({ input, errors }) =>
365
+ Effect.gen(function* () {
366
+ const userService = yield* UserService
367
+ const user = yield* userService.findById(input.id)
368
+
369
+ if (!user) {
370
+ // Use oRPC's type-safe errors
371
+ throw errors.NOT_FOUND({ id: input.id })
372
+ }
373
+
374
+ return user
375
+ })
376
+ )
377
+ ```
378
+
379
+ ## Using Effect.fn vs Effect.gen
380
+
381
+ Both generator syntaxes are supported:
382
+
383
+ ```ts
384
+ // Using Effect.fn (recommended for procedures)
385
+ const procedureWithFn = effectOs
386
+ .input(z.object({ id: z.string() }))
387
+ .effect(
388
+ Effect.fn(function* ({ input }) {
389
+ const service = yield* MyService
390
+ return yield* service.doSomething(input.id)
391
+ })
392
+ )
393
+
394
+ // Using Effect.gen with arrow function
395
+ const procedureWithGen = effectOs
396
+ .input(z.object({ id: z.string() }))
397
+ .effect(({ input }) =>
398
+ Effect.gen(function* () {
399
+ const service = yield* MyService
400
+ return yield* service.doSomething(input.id)
401
+ })
402
+ )
403
+
404
+ // Simple effects without generators
405
+ const simpleProcedure = effectOs
406
+ .input(z.object({ name: z.string() }))
407
+ .effect(({ input }) =>
408
+ Effect.succeed(`Hello, ${input.name}!`)
409
+ )
410
+ ```
411
+
412
+ ## Traceable Spans
413
+
414
+ All Effect procedures are automatically traced with `Effect.withSpan`. By default, the span name is the procedure path (e.g., `users.getUser`):
415
+
416
+ ```ts
417
+ // Router structure determines span names automatically
418
+ const router = os.router({
419
+ users: os.router({
420
+ // Span name: "users.get"
421
+ get: effectOs
422
+ .input(z.object({ id: z.string() }))
423
+ .effect(
424
+ Effect.fn(function* ({ input }) {
425
+ const userService = yield* UserService
426
+ return yield* userService.findById(input.id)
427
+ })
428
+ ),
429
+ // Span name: "users.create"
430
+ create: effectOs
431
+ .input(z.object({ name: z.string() }))
432
+ .effect(
433
+ Effect.fn(function* ({ input }) {
434
+ const userService = yield* UserService
435
+ return yield* userService.create(input.name)
436
+ })
437
+ ),
438
+ })
439
+ })
440
+ ```
441
+
442
+ Use `.traced()` to override the default span name:
443
+
444
+ ```ts
445
+ const getUser = effectOs
446
+ .input(z.object({ id: z.string() }))
447
+ .traced('custom.span.name') // Override the default path-based name
448
+ .effect(
449
+ Effect.fn(function* ({ input }) {
450
+ const userService = yield* UserService
451
+ return yield* userService.findById(input.id)
452
+ })
453
+ )
454
+ ```
455
+
456
+ ### Enabling OpenTelemetry
457
+
458
+ To enable tracing, include the OpenTelemetry layer in your runtime:
459
+
460
+ ```ts
461
+ import { NodeSdk } from '@effect/opentelemetry'
462
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
463
+ import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
464
+
465
+ const TracingLive = NodeSdk.layer(Effect.sync(() => ({
466
+ resource: { serviceName: 'my-service' },
467
+ spanProcessor: [new SimpleSpanProcessor(new OTLPTraceExporter())]
468
+ })))
469
+
470
+ const AppLive = Layer.mergeAll(
471
+ UserServiceLive,
472
+ TracingLive
473
+ )
474
+
475
+ const runtime = ManagedRuntime.make(AppLive)
476
+ const effectOs = makeEffectORPC(runtime)
477
+ ```
478
+
479
+ ### Error Stack Traces
480
+
481
+ When an Effect procedure fails, the span includes a properly formatted stack trace pointing to the definition site:
482
+
483
+ ```
484
+ MyCustomError: Something went wrong
485
+ at <anonymous> (/app/src/procedures.ts:42:28)
486
+ at users.getById (/app/src/procedures.ts:41:35)
487
+ ```
488
+
489
+ ## API Reference
490
+
491
+ ### `makeEffectORPC(runtime, builder?)`
492
+
493
+ Creates an Effect-aware procedure builder.
494
+
495
+ - `runtime` - A `ManagedRuntime<R, E>` instance that provides services for Effect procedures
496
+ - `builder` (optional) - An oRPC Builder instance to wrap. Defaults to `os` from `@orpc/server`
497
+
498
+ Returns an `EffectBuilder` instance.
499
+
500
+ ```ts
501
+ // With default builder
502
+ const effectOs = makeEffectORPC(runtime)
503
+
504
+ // With customized builder
505
+ const effectAuthedOs = makeEffectORPC(runtime, authedBuilder)
506
+ ```
507
+
508
+ ### `EffectBuilder`
509
+
510
+ Wraps an oRPC Builder with Effect support. Available methods:
511
+
512
+ | Method | Description |
513
+ | ------------------ | ------------------------------------------------------------------------------- |
514
+ | `.errors(map)` | Add type-safe custom errors |
515
+ | `.meta(meta)` | Set procedure metadata |
516
+ | `.route(route)` | Configure OpenAPI route |
517
+ | `.input(schema)` | Define input validation schema |
518
+ | `.output(schema)` | Define output validation schema |
519
+ | `.use(middleware)` | Add middleware |
520
+ | `.traced(name)` | Add a traceable span for telemetry (optional, defaults to the procedure's path) |
521
+ | `.effect(handler)` | Define the Effect handler |
522
+
523
+ ### `EffectDecoratedProcedure`
524
+
525
+ The result of calling `.effect()`. Extends standard oRPC `DecoratedProcedure` with Effect type preservation.
526
+
527
+ | Method | Description |
528
+ | ----------------------- | --------------------------------------------- |
529
+ | `.errors(map)` | Add more custom errors |
530
+ | `.meta(meta)` | Update metadata |
531
+ | `.route(route)` | Update route configuration |
532
+ | `.use(middleware)` | Add middleware |
533
+ | `.callable(options?)` | Make procedure directly invocable |
534
+ | `.actionable(options?)` | Make procedure compatible with server actions |
535
+
536
+ ## License
537
+
538
+ MIT