effect-orpc 0.2.1 → 0.3.0

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 CHANGED
@@ -7,7 +7,7 @@ Inspired by [effect-trpc](https://github.com/mikearnaldi/effect-trpc).
7
7
  ## Features
8
8
 
9
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
10
+ - **Type-safe service injection** - Add base services with `.provide(layer)` or pass a `Layer` / `ManagedRuntime<R>` directly
11
11
  - **Tagged errors** - Create Effect-native error classes with `ORPCTaggedError` that integrate with oRPC's error handling
12
12
  - **Full oRPC compatibility** - Mix Effect procedures with standard oRPC procedures in the same router
13
13
  - **Telemetry support with automatic tracing** - Procedures are automatically traced with OpenTelemetry-compatible spans. Customize span names with `.traced()`.
@@ -65,13 +65,21 @@ class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
65
65
  status: 404,
66
66
  }) {}
67
67
 
68
- // Create runtime with your services
69
- const runtime = ManagedRuntime.make(UsersRepo.Default);
70
- // Create Effect-aware oRPC builder from an other (optional) base oRPC builder and provide tagged errors
71
- const effectOs = makeEffectORPC(runtime, authedOs).errors({
68
+ // Create an Effect-aware oRPC builder with your service layer, optionally from
69
+ // another base oRPC builder, and provide tagged errors.
70
+ const effectOs = makeEffectORPC(UsersRepo.Default, authedOs).errors({
72
71
  UserNotFoundError,
73
72
  });
74
73
 
74
+ // You can also pass an explicit ManagedRuntime if you need lifecycle control:
75
+ // const runtime = ManagedRuntime.make(UsersRepo.Default);
76
+ // const effectOs = makeEffectORPC(runtime, authedOs).errors({ UserNotFoundError });
77
+
78
+ // Or start with only the builder and provide the layer later:
79
+ // const effectOs = makeEffectORPC(authedOs)
80
+ // .provide(UsersRepo.Default)
81
+ // .errors({ UserNotFoundError });
82
+
75
83
  // Create the router with mixed procedures
76
84
  export const router = {
77
85
  health: os.handler(() => "ok"),
@@ -91,10 +99,10 @@ export type Router = typeof router;
91
99
 
92
100
  ## Type Safety
93
101
 
94
- 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:
102
+ The wrapper enforces that Effect procedures only use services provided by `.provide(layer)`, request-scoped `.provide(tag, provider)` calls, or an initial `Layer` / `ManagedRuntime`. If you try to use a service that isn't available, you'll get a compile-time error:
95
103
 
96
104
  ```ts
97
- import { Context, Effect, Layer, ManagedRuntime } from "effect";
105
+ import { Context, Effect, Layer } from "effect";
98
106
  import { makeEffectORPC } from "effect-orpc";
99
107
 
100
108
  class ProvidedService extends Context.Tag("ProvidedService")<
@@ -107,21 +115,19 @@ class MissingService extends Context.Tag("MissingService")<
107
115
  { doSomething: () => Effect.Effect<string> }
108
116
  >() {}
109
117
 
110
- const runtime = ManagedRuntime.make(
111
- Layer.succeed(ProvidedService, {
112
- doSomething: () => Effect.succeed("ok"),
113
- }),
114
- );
118
+ const AppLive = Layer.succeed(ProvidedService, {
119
+ doSomething: () => Effect.succeed("ok"),
120
+ });
115
121
 
116
- const effectOs = makeEffectORPC(runtime);
122
+ const effectOs = makeEffectORPC(AppLive);
117
123
 
118
- // ✅ This compiles - ProvidedService is in the runtime
124
+ // ✅ This compiles - ProvidedService is provided by AppLive
119
125
  const works = effectOs.effect(function* () {
120
126
  const service = yield* ProvidedService;
121
127
  return yield* service.doSomething();
122
128
  });
123
129
 
124
- // ❌ This fails to compile - MissingService is not in the runtime
130
+ // ❌ This fails to compile - MissingService is not provided
125
131
  const fails = effectOs.effect(function* () {
126
132
  const service = yield* MissingService; // Type error!
127
133
  return yield* service.doSomething();
@@ -239,7 +245,7 @@ const getUser = effectOs
239
245
 
240
246
  ### Enabling OpenTelemetry
241
247
 
242
- To enable tracing, include the OpenTelemetry layer in your runtime:
248
+ To enable tracing, include the OpenTelemetry layer in your application layer:
243
249
 
244
250
  ```ts
245
251
  import { NodeSdk } from "@effect/opentelemetry";
@@ -255,8 +261,7 @@ const TracingLive = NodeSdk.layer(
255
261
 
256
262
  const AppLive = Layer.mergeAll(UserServiceLive, TracingLive);
257
263
 
258
- const runtime = ManagedRuntime.make(AppLive);
259
- const effectOs = makeEffectORPC(runtime);
264
+ const effectOs = makeEffectORPC(AppLive);
260
265
  ```
261
266
 
262
267
  ### Error Stack Traces
@@ -269,24 +274,133 @@ MyCustomError: Something went wrong
269
274
  at users.getById (/app/src/procedures.ts:41:35)
270
275
  ```
271
276
 
277
+ ## Effect middleware
278
+
279
+ `.use(...)` accepts generator-based Effect middleware in addition to native oRPC
280
+ middleware. Two patterns are supported:
281
+
282
+ **Gate** — run auth or validation side effects, then let the pipeline continue
283
+ automatically (no need to call `next`):
284
+
285
+ ```ts
286
+ effectOs.use(function* () {
287
+ const user = yield* CurrentUser;
288
+ yield* requireActiveUser(user);
289
+ });
290
+ ```
291
+
292
+ **Wrap** — call downstream explicitly and return the result. When porting oRPC
293
+ middleware that uses `return next(...)`, use `return yield* next(...)`:
294
+
295
+ ```ts
296
+ effectOs.use(function* ({ next }) {
297
+ const user = yield* CurrentUser;
298
+ yield* requireActiveUser(user);
299
+
300
+ return yield* next({
301
+ context: { userId: user.id },
302
+ });
303
+ });
304
+ ```
305
+
306
+ To transform the downstream output, capture `next()` and pass through `output`:
307
+
308
+ ```ts
309
+ effectOs.use(function* ({ next }, _input, output) {
310
+ const result = yield* next();
311
+ return yield* output(`${result.output}-wrapped`);
312
+ });
313
+ ```
314
+
315
+ Calling `yield* next()` without returning its result still runs the handler once,
316
+ but prefer `return yield* next(...)` so the pipeline receives your middleware
317
+ result explicitly.
318
+
319
+ ### Runtime boundaries and fiber context continuity
320
+
321
+ `effect-orpc` batches contiguous Effect-native steps into one runtime boundary.
322
+ Effect-native steps are `.provide(...)`, `.provideOptional(...)`, generator
323
+ `.use(function* ...)`, and `.effect(function* ...)`.
324
+
325
+ ```ts
326
+ makeEffectORPC(AppLive)
327
+ .provide(CurrentUser, ({ context }) => Effect.succeed(context.user))
328
+ .use(function* ({ next }) {
329
+ const user = yield* CurrentUser;
330
+ return yield* next({ context: { userId: user.id } });
331
+ })
332
+ .effect(function* ({ context }) {
333
+ const user = yield* CurrentUser;
334
+ return `${context.userId}:${user.id}`;
335
+ });
336
+ ```
337
+
338
+ The example above runs the provider, middleware, and handler inside a single
339
+ internal `runtime.runPromiseExit(...)` call.
340
+
341
+ A native oRPC middleware breaks the contiguous Effect pipeline. Pending Effect
342
+ steps are flushed into one generated oRPC middleware before the native middleware:
343
+
344
+ ```ts
345
+ makeEffectORPC(AppLive)
346
+ .provide(CurrentUser, getCurrentUser) // Effect group #1
347
+ .use(function* ({ next }) {
348
+ return yield* next();
349
+ })
350
+ .use(({ next }) => next()) // native oRPC middleware; flushes group #1
351
+ .use(function* ({ next }) {
352
+ return yield* next();
353
+ }) // Effect group #2
354
+ .effect(function* () {
355
+ return "ok";
356
+ });
357
+ ```
358
+
359
+ That split still creates multiple runtime boundaries. If the Node bridge is
360
+ installed, however, `effect-orpc` carries the current `FiberRefs` through the
361
+ native oRPC continuation and merges them into the next Effect boundary:
362
+
363
+ ```ts
364
+ import "effect-orpc/node";
365
+ ```
366
+
367
+ Use the side-effect import when you only need continuity across internal
368
+ `effect-orpc` boundaries, such as Effect group #1 → native oRPC middleware →
369
+ Effect group #2.
370
+
371
+ Procedure-level `.provide*` after a native `.handler(...)` has no Effect handler
372
+ boundary to attach to, so it is installed as an oRPC middleware that runs its
373
+ provider Effect through the runtime:
374
+
375
+ ```ts
376
+ makeEffectORPC(AppLive)
377
+ .handler(() => "ok") // native oRPC handler
378
+ .provide(CurrentUser, getCurrentUser); // fallback provider middleware
379
+ ```
380
+
381
+ If you want `.provide*` and Effect middleware to batch with the handler, use
382
+ `.effect(function* ...)` instead of `.handler(...)`.
383
+
272
384
  ## Request-Scoped Fiber Context
273
385
 
274
- If you run `effect-orpc` inside a framework such as Hono, the handler executes
275
- through the runtime boundary and will not automatically inherit request-local
276
- `FiberRef` state from outer middleware.
386
+ The `/node` entrypoint installs a bridge backed by `AsyncLocalStorage`. It has
387
+ two uses:
388
+
389
+ - `import "effect-orpc/node"` installs the bridge passively. This is enough for
390
+ `effect-orpc` to propagate `FiberRefs` across its own split runtime boundaries.
391
+ - `withFiberContext(() => next())` actively seeds the bridge from an external
392
+ Effect scope, such as framework middleware wrapping an oRPC handler.
277
393
 
278
- To preserve request-scoped logs, tracing annotations, and
279
- other fiber-local state, wrap the framework continuation with `withFiberContext` from
280
- `effect-orpc/node`.
394
+ Use `withFiberContext` when request-local `FiberRef` state is created outside the
395
+ oRPC pipeline and should be visible inside handlers:
281
396
 
282
397
  ```ts
283
398
  import { Hono } from "hono";
284
- import { Effect, ManagedRuntime } from "effect";
399
+ import { Effect } from "effect";
285
400
  import { makeEffectORPC } from "effect-orpc";
286
401
  import { withFiberContext } from "effect-orpc/node";
287
402
 
288
- const runtime = ManagedRuntime.make(AppLive);
289
- const effectOs = makeEffectORPC(runtime);
403
+ const effectOs = makeEffectORPC(AppLive);
290
404
  const app = new Hono();
291
405
 
292
406
  app.use("*", async (c, next) => {
@@ -302,31 +416,27 @@ app.use("*", async (c, next) => {
302
416
  });
303
417
  ```
304
418
 
305
- When a captured fiber context and the `ManagedRuntime` both provide the same
306
- service, `effect-orpc` prioritizes the captured context. The runtime is treated
307
- as the application-wide base layer, while `withFiberContext` preserves the
308
- more specific request-scoped values from outer middleware. This prevents
309
- request-local references such as request IDs, logging annotations, tracing
310
- context, or scoped overrides from being replaced by runtime defaults when the
311
- handler crosses the runtime boundary.
419
+ Importing `withFiberContext` from `effect-orpc/node` also installs the bridge, so
420
+ you do not need a separate side-effect import.
312
421
 
313
- The reason for the separate `/node` entrypoint is that `withFiberContext` relies
314
- on Node/Bun's `AsyncLocalStorage` from `node:async_hooks` to carry Effect
315
- `FiberRef` state across framework async boundaries. The main package stays
316
- runtime-agnostic.
422
+ When a captured fiber context and the application `Layer` / `ManagedRuntime`
423
+ both provide the same service, `effect-orpc` prioritizes the captured context.
424
+ The application layer is treated as the base layer, while the bridge preserves more specific
425
+ request-scoped values such as request IDs, logging annotations, tracing context,
426
+ or scoped overrides when crossing runtime boundaries.
317
427
 
318
- If you do not need framework-to-handler fiber propagation, you do not need the
319
- `/node` entrypoint at all.
428
+ The main package stays runtime-agnostic; `/node` is separate because the bridge
429
+ relies on `AsyncLocalStorage` from `node:async_hooks`.
320
430
 
321
431
  ## Contract-First Usage
322
432
 
323
- Use `implementEffect(contract, runtime)` when you already have an oRPC contract
324
- and want to keep contract-first enforcement while adding Effect-native handlers.
325
- Use `makeEffectORPC(runtime, builder?)` when you want to build procedures
326
- directly from an oRPC builder.
433
+ Use `implementEffect(contract, layerOrRuntime)` when you already have an oRPC
434
+ contract and want to keep contract-first enforcement while adding Effect-native
435
+ handlers. Use `makeEffectORPC(layerOrRuntime, builder?)` when you want to build
436
+ procedures directly from an oRPC builder.
327
437
 
328
438
  ```ts
329
- import { Effect, ManagedRuntime } from "effect";
439
+ import { Effect } from "effect";
330
440
  import { eoc, implementEffect } from "effect-orpc";
331
441
  import z from "zod";
332
442
 
@@ -346,8 +456,7 @@ const contract = {
346
456
  },
347
457
  };
348
458
 
349
- const runtime = ManagedRuntime.make(UsersRepo.Default);
350
- const oe = implementEffect(contract, runtime);
459
+ const oe = implementEffect(contract, UsersRepo.Default);
351
460
 
352
461
  export const router = oe.router({
353
462
  users: {
@@ -369,34 +478,42 @@ directly from the `ORPCTaggedError` class.
369
478
 
370
479
  ## API Reference
371
480
 
372
- ### `makeEffectORPC(runtime, builder?)`
481
+ ### `makeEffectORPC(layerOrRuntime, builder?)`
373
482
 
374
- Creates an Effect-aware procedure builder.
375
-
376
- - `runtime` - A `ManagedRuntime<R, E>` instance that provides services for Effect procedures
377
- - `builder` (optional) - An oRPC Builder instance to wrap. Defaults to `os` from `@orpc/server`
483
+ Creates an Effect-aware procedure builder. The recommended default is to pass
484
+ your application `Layer` up front.
378
485
 
379
486
  Returns an `EffectBuilder` instance.
380
487
 
381
488
  ```ts
382
489
  // With default builder
383
- const effectOs = makeEffectORPC(runtime);
490
+ const effectOs = makeEffectORPC(AppLive);
384
491
 
385
492
  // With customized builder
386
- const effectAuthedOs = makeEffectORPC(runtime, authedBuilder);
493
+ const effectAuthedOs = makeEffectORPC(AppLive, authedBuilder);
494
+ ```
495
+
496
+ You can also start from a builder and provide the layer later, or pass a
497
+ `ManagedRuntime` when you need explicit runtime lifecycle control:
498
+
499
+ ```ts
500
+ const effectOsWithProvidedLayer = makeEffectORPC().provide(AppLive);
501
+ const effectAuthedOsWithProvidedLayer =
502
+ makeEffectORPC(authedBuilder).provide(AppLive);
503
+ const effectOsFromRuntime = makeEffectORPC(runtime);
387
504
  ```
388
505
 
389
- ### `implementEffect(contract, runtime)`
506
+ ### `implementEffect(contract, layerOrRuntime)`
390
507
 
391
508
  Creates an Effect-aware contract implementer.
392
509
 
393
510
  - `contract` - An oRPC contract router built with `oc`
394
- - `runtime` - A `ManagedRuntime<R, E>` instance that provides services for Effect procedures
511
+ - `layerOrRuntime` - A `Layer<R, E, never>` or `ManagedRuntime<R, E>` that provides services for Effect procedures
395
512
 
396
513
  Returns a contract-shaped implementer tree whose leaves support `.effect(...)`.
397
514
 
398
515
  ```ts
399
- const oe = implementEffect(contract, runtime);
516
+ const oe = implementEffect(contract, AppLive);
400
517
 
401
518
  const router = oe.router({
402
519
  users: {
@@ -436,26 +553,28 @@ const contract = {
436
553
 
437
554
  Wraps an oRPC Builder with Effect support. Available methods:
438
555
 
439
- | Method | Description |
440
- | ------------------- | ------------------------------------------------------------------------------- |
441
- | `.$config(config)` | Set or override the builder config |
442
- | `.$context<U>()` | Set or override the initial context type |
443
- | `.$meta(meta)` | Set or override the initial metadata |
444
- | `.$route(route)` | Set or override the initial route configuration |
445
- | `.$input(schema)` | Set or override the initial input schema |
446
- | `.errors(map)` | Add type-safe custom errors |
447
- | `.meta(meta)` | Set procedure metadata (merged with existing) |
448
- | `.route(route)` | Configure OpenAPI route (merged with existing) |
449
- | `.input(schema)` | Define input validation schema |
450
- | `.output(schema)` | Define output validation schema |
451
- | `.use(middleware)` | Add middleware |
452
- | `.traced(name)` | Add a traceable span for telemetry (optional, defaults to the procedure's path) |
453
- | `.handler(handler)` | Define a non-Effect handler (standard oRPC handler) |
454
- | `.effect(handler)` | Define the Effect handler |
455
- | `.prefix(prefix)` | Prefix all procedures in the router (for OpenAPI) |
456
- | `.tag(...tags)` | Add tags to all procedures in the router (for OpenAPI) |
457
- | `.router(router)` | Apply all options to a router |
458
- | `.lazy(loader)` | Create and apply options to a lazy-loaded router |
556
+ | Method | Description |
557
+ | ------------------- | ------------------------------------------------------------------------------------ |
558
+ | `.$config(config)` | Set or override the builder config |
559
+ | `.$context<U>()` | Set or override the initial context type |
560
+ | `.$meta(meta)` | Set or override the initial metadata |
561
+ | `.$route(route)` | Set or override the initial route configuration |
562
+ | `.$input(schema)` | Set or override the initial input schema |
563
+ | `.errors(map)` | Add type-safe custom errors |
564
+ | `.meta(meta)` | Set procedure metadata (merged with existing) |
565
+ | `.route(route)` | Configure OpenAPI route (merged with existing) |
566
+ | `.input(schema)` | Define input validation schema |
567
+ | `.output(schema)` | Define output validation schema |
568
+ | `.provide(layer)` | Provide a base Effect layer to downstream Effect middleware and handlers |
569
+ | `.provide(tag, fn)` | Provide a request-scoped Effect service to downstream Effect middleware and handlers |
570
+ | `.use(middleware)` | Add middleware |
571
+ | `.traced(name)` | Add a traceable span for telemetry (optional, defaults to the procedure's path) |
572
+ | `.handler(handler)` | Define a non-Effect handler (standard oRPC handler) |
573
+ | `.effect(handler)` | Define the Effect handler |
574
+ | `.prefix(prefix)` | Prefix all procedures in the router (for OpenAPI) |
575
+ | `.tag(...tags)` | Add tags to all procedures in the router (for OpenAPI) |
576
+ | `.router(router)` | Apply all options to a router |
577
+ | `.lazy(loader)` | Create and apply options to a lazy-loaded router |
459
578
 
460
579
  ### `EffectDecoratedProcedure`
461
580
 
@@ -466,6 +585,8 @@ The result of calling `.effect()`. Extends standard oRPC `DecoratedProcedure` wi
466
585
  | `.errors(map)` | Add more custom errors |
467
586
  | `.meta(meta)` | Update metadata (merged with existing) |
468
587
  | `.route(route)` | Update route configuration (merged) |
588
+ | `.provide(layer)` | Provide a base Effect layer |
589
+ | `.provide(tag, fn)` | Provide a request-scoped Effect service |
469
590
  | `.use(middleware)` | Add middleware |
470
591
  | `.callable(options?)` | Make procedure directly invocable |
471
592
  | `.actionable(options?)` | Make procedure compatible with server actions |
@@ -6,9 +6,13 @@ function installFiberContextBridge(nextBridge) {
6
6
  function getCurrentFiberRefs() {
7
7
  return bridge?.getCurrentFiberRefs();
8
8
  }
9
+ function runWithFiberRefs(fiberRefs, fn) {
10
+ return bridge?.runWithFiberRefs ? bridge.runWithFiberRefs(fiberRefs, fn) : fn();
11
+ }
9
12
 
10
13
  export {
11
14
  installFiberContextBridge,
12
- getCurrentFiberRefs
15
+ getCurrentFiberRefs,
16
+ runWithFiberRefs
13
17
  };
14
- //# sourceMappingURL=chunk-VOWRLWZZ.js.map
18
+ //# sourceMappingURL=chunk-IJP6L2XR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/fiber-context-bridge.ts"],"sourcesContent":["import type { FiberRefs } from \"effect\";\n\nexport interface FiberContextBridge {\n readonly getCurrentFiberRefs: () => FiberRefs.FiberRefs | undefined;\n readonly runWithFiberRefs?: <T>(\n fiberRefs: FiberRefs.FiberRefs,\n fn: () => Promise<T>,\n ) => Promise<T>;\n}\n\nlet bridge: FiberContextBridge | undefined;\n\nexport function installFiberContextBridge(\n nextBridge: FiberContextBridge | undefined,\n): void {\n bridge = nextBridge;\n}\n\nexport function getCurrentFiberRefs(): FiberRefs.FiberRefs | undefined {\n return bridge?.getCurrentFiberRefs();\n}\n\nexport function runWithFiberRefs<T>(\n fiberRefs: FiberRefs.FiberRefs,\n fn: () => Promise<T>,\n): Promise<T> {\n return bridge?.runWithFiberRefs\n ? bridge.runWithFiberRefs(fiberRefs, fn)\n : fn();\n}\n"],"mappings":";AAUA,IAAI;AAEG,SAAS,0BACd,YACM;AACN,WAAS;AACX;AAEO,SAAS,sBAAuD;AACrE,SAAO,QAAQ,oBAAoB;AACrC;AAEO,SAAS,iBACd,WACA,IACY;AACZ,SAAO,QAAQ,mBACX,OAAO,iBAAiB,WAAW,EAAE,IACrC,GAAG;AACT;","names":[]}