better-effect 0.13.0 → 0.14.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.
Files changed (109) hide show
  1. package/README.md +337 -88
  2. package/dist/adapters/iti.d.mts +2 -3
  3. package/dist/adapters/iti.d.mts.map +1 -1
  4. package/dist/adapters/iti.mjs +2 -2
  5. package/dist/bun.d.mts +75 -23
  6. package/dist/bun.d.mts.map +1 -1
  7. package/dist/bun.mjs +170 -33
  8. package/dist/bun.mjs.map +1 -1
  9. package/dist/{context-BphfFUMO.mjs → context-DSGSqcJj.mjs} +12 -3
  10. package/dist/context-DSGSqcJj.mjs.map +1 -0
  11. package/dist/{current-request-DDKNyymv.mjs → current-request-Bc1YHL0w.mjs} +3 -2
  12. package/dist/{current-request-DDKNyymv.mjs.map → current-request-Bc1YHL0w.mjs.map} +1 -1
  13. package/dist/{current-request-CIU7AHhF.d.mts → current-request-Dm-QQ3Xr.d.mts} +4 -4
  14. package/dist/current-request-Dm-QQ3Xr.d.mts.map +1 -0
  15. package/dist/{effect-CPdV7Yv8.mjs → effect-W0IQXY8P.mjs} +21 -2
  16. package/dist/effect-W0IQXY8P.mjs.map +1 -0
  17. package/dist/{errors-BKcsQ0ip.d.mts → errors-CpHMzPiP.d.mts} +6 -2
  18. package/dist/errors-CpHMzPiP.d.mts.map +1 -0
  19. package/dist/hono.d.mts +63 -27
  20. package/dist/hono.d.mts.map +1 -1
  21. package/dist/hono.mjs +111 -45
  22. package/dist/hono.mjs.map +1 -1
  23. package/dist/{index-CCi07kUw.d.mts → index-3Bkl-sxS.d.mts} +7 -2
  24. package/dist/{index-CCi07kUw.d.mts.map → index-3Bkl-sxS.d.mts.map} +1 -1
  25. package/dist/{index-cXgyMa0v.d.mts → index-C2ed9Ilq.d.mts} +32 -7
  26. package/dist/index-C2ed9Ilq.d.mts.map +1 -0
  27. package/dist/{index-BodMy6rO.d.mts → index-ClNndn2-.d.mts} +93 -63
  28. package/dist/index-ClNndn2-.d.mts.map +1 -0
  29. package/dist/{index-DAKn0qHk.d.mts → index-Cy1VsU-u.d.mts} +2 -4
  30. package/dist/index-Cy1VsU-u.d.mts.map +1 -0
  31. package/dist/index.d.mts +6 -8
  32. package/dist/index.mjs +8 -9
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/{internal-identity-CbXoZIfw.mjs → internal-identity-BuHBtP0k.mjs} +2 -2
  35. package/dist/{internal-identity-CbXoZIfw.mjs.map → internal-identity-BuHBtP0k.mjs.map} +1 -1
  36. package/dist/layer-2kjHVv4N.mjs +343 -0
  37. package/dist/layer-2kjHVv4N.mjs.map +1 -0
  38. package/dist/layer-C2OaqTmB.d.mts +987 -0
  39. package/dist/layer-C2OaqTmB.d.mts.map +1 -0
  40. package/dist/next.d.mts +102 -26
  41. package/dist/next.d.mts.map +1 -1
  42. package/dist/next.mjs +157 -40
  43. package/dist/next.mjs.map +1 -1
  44. package/dist/{node-context-DczjAcrX.d.mts → node-context-1YEjrU9g.d.mts} +2 -2
  45. package/dist/{node-context-DczjAcrX.d.mts.map → node-context-1YEjrU9g.d.mts.map} +1 -1
  46. package/dist/{node-context-Ca18oNSg.mjs → node-context-g3bCcY9t.mjs} +2 -2
  47. package/dist/{node-context-Ca18oNSg.mjs.map → node-context-g3bCcY9t.mjs.map} +1 -1
  48. package/dist/node.d.mts +11 -6
  49. package/dist/node.d.mts.map +1 -1
  50. package/dist/node.mjs +156 -10
  51. package/dist/node.mjs.map +1 -1
  52. package/dist/opentelemetry.d.mts +20 -2
  53. package/dist/opentelemetry.d.mts.map +1 -1
  54. package/dist/opentelemetry.mjs +423 -5
  55. package/dist/opentelemetry.mjs.map +1 -1
  56. package/dist/{registration-drnZk0SY.mjs → registration-Bwe2GbVu.mjs} +8 -2
  57. package/dist/registration-Bwe2GbVu.mjs.map +1 -0
  58. package/dist/runtime/explicit.d.mts +2 -2
  59. package/dist/runtime/explicit.mjs +1 -1
  60. package/dist/runtime/node.d.mts +3 -3
  61. package/dist/runtime/node.mjs +2 -2
  62. package/dist/runtime-qpcVwaz_.mjs +1680 -0
  63. package/dist/runtime-qpcVwaz_.mjs.map +1 -0
  64. package/dist/service-CO9VJQYa.mjs +74 -0
  65. package/dist/service-CO9VJQYa.mjs.map +1 -0
  66. package/dist/{signal-nfgFyNCk.mjs → signal-5Rg9bUUe.mjs} +2 -2
  67. package/dist/{signal-nfgFyNCk.mjs.map → signal-5Rg9bUUe.mjs.map} +1 -1
  68. package/dist/{standard-services-vnGIafkS.mjs → standard-services-B-iMM_W7.mjs} +38 -4
  69. package/dist/standard-services-B-iMM_W7.mjs.map +1 -0
  70. package/dist/standard-services.d.mts +4 -4
  71. package/dist/standard-services.mjs +3 -3
  72. package/dist/testing.d.mts +25 -7
  73. package/dist/testing.d.mts.map +1 -1
  74. package/dist/testing.mjs +51 -7
  75. package/dist/testing.mjs.map +1 -1
  76. package/dist/{types-oihI-ESv.d.mts → types-BFTqCCcw.d.mts} +32 -5
  77. package/dist/types-BFTqCCcw.d.mts.map +1 -0
  78. package/dist/web-effect-B9hp9jIe.mjs +380 -0
  79. package/dist/web-effect-B9hp9jIe.mjs.map +1 -0
  80. package/dist/web.d.mts +22 -13
  81. package/dist/web.d.mts.map +1 -1
  82. package/dist/web.mjs +1 -1
  83. package/package.json +10 -16
  84. package/dist/context-BphfFUMO.mjs.map +0 -1
  85. package/dist/current-request-CIU7AHhF.d.mts.map +0 -1
  86. package/dist/disposable-CP-Q2H3l.mjs +0 -57
  87. package/dist/disposable-CP-Q2H3l.mjs.map +0 -1
  88. package/dist/effect-CPdV7Yv8.mjs.map +0 -1
  89. package/dist/errors-BKcsQ0ip.d.mts.map +0 -1
  90. package/dist/index-AiKM71-u.d.mts +0 -376
  91. package/dist/index-AiKM71-u.d.mts.map +0 -1
  92. package/dist/index-BodMy6rO.d.mts.map +0 -1
  93. package/dist/index-DAKn0qHk.d.mts.map +0 -1
  94. package/dist/index-cXgyMa0v.d.mts.map +0 -1
  95. package/dist/layer-D16GKxuX.mjs +0 -269
  96. package/dist/layer-D16GKxuX.mjs.map +0 -1
  97. package/dist/observer-9nhbL7Pr.d.mts +0 -68
  98. package/dist/observer-9nhbL7Pr.d.mts.map +0 -1
  99. package/dist/outcome-DI8LPIQ4.d.mts +0 -387
  100. package/dist/outcome-DI8LPIQ4.d.mts.map +0 -1
  101. package/dist/program-metadata-BxjGkT17.mjs +0 -260
  102. package/dist/program-metadata-BxjGkT17.mjs.map +0 -1
  103. package/dist/registration-drnZk0SY.mjs.map +0 -1
  104. package/dist/runtime-C_55ofnL.mjs +0 -791
  105. package/dist/runtime-C_55ofnL.mjs.map +0 -1
  106. package/dist/standard-services-vnGIafkS.mjs.map +0 -1
  107. package/dist/types-oihI-ESv.d.mts.map +0 -1
  108. package/dist/web-Bq3MfMrj.mjs +0 -147
  109. package/dist/web-Bq3MfMrj.mjs.map +0 -1
package/README.md CHANGED
@@ -11,8 +11,8 @@ bun add better-effect better-result
11
11
  ```
12
12
 
13
13
  The published Runtime entrypoint is officially supported on Node.js and Bun.
14
- The tested runtime matrix is Node.js 24 and Bun 1.3.14, and the default runtime
15
- context uses Node/Bun async context propagation. `bun run check` also deletes
14
+ The repository uses the latest Bun release by default and the current Node.js
15
+ LTS for interoperability smoke tests. `bun run check` also deletes
16
16
  and rebuilds `dist`, packs the result into a temporary consumer, and runs the
17
17
  full Node/Bun `NodeRuntime` child-process suite.
18
18
 
@@ -149,6 +149,61 @@ const inspectDatabase = Effect.fn(async function* () {
149
149
  await runtime.run(inspectDatabase)
150
150
  ```
151
151
 
152
+ Integration authors that need to trigger work later can capture the Runtime's
153
+ non-owning executor capability from an execution or Layer acquisition:
154
+
155
+ ```ts
156
+ const captureExecutor = Effect.fn(async function* () {
157
+ const executor = yield* Runtime.executor<Database>()
158
+ return Result.ok(executor)
159
+ })
160
+
161
+ const executor = (await runtime.run(captureExecutor)).unwrap()
162
+ await executor.run(inspectDatabase)
163
+ ```
164
+
165
+ `Runtime.Executor<R>` exposes `run`, `runWith`, and the opt-in
166
+ `runWithManaged` capability. The first two start a child execution and resolve
167
+ after its Scope closes; `runWithManaged` additionally separates readiness from
168
+ completion for boundaries such as `WebEffect.streamWith`, while keeping the
169
+ same request-local Services and Scope active until completion. The executor
170
+ does not expose `dispose`, `warmup`, `inspect`, backend or Scope ownership.
171
+ Applications normally use `runtime.run`; the contextual executor is mainly for
172
+ framework adapters and long-lived components.
173
+
174
+ For work that must live only as long as the current execution or lifecycle
175
+ Scope, use `Effect.forkScoped`. It returns immediately with a small task handle;
176
+ the task inherits the current Services and resolver, receives its own
177
+ cooperative-cancellation signal, and is interrupted and awaited before the
178
+ parent Scope releases its resources:
179
+
180
+ ```ts
181
+ const pollOutbox = Effect.fn(async function* () {
182
+ const signal = yield* CurrentAbortSignal
183
+
184
+ while (!signal.aborted) {
185
+ await pollOnce()
186
+ }
187
+
188
+ return Result.ok(undefined)
189
+ })
190
+
191
+ const OutboxLive = Layer.effectDiscard(
192
+ Effect.fn(async function* () {
193
+ const task = yield* Effect.forkScoped(pollOutbox)
194
+ await task.awaitExit()
195
+
196
+ return Result.ok(undefined)
197
+ })
198
+ )
199
+ ```
200
+
201
+ Use `task.await()` when the child Result should be returned or its defect should
202
+ be thrown. Use `task.awaitExit()` when interruption and defects must be
203
+ inspected without throwing. `Runtime.inspect()` reports active scoped tasks,
204
+ and Runtime observers receive task start/end events linked to their parent
205
+ execution; scoped tasks do not create independent Runtime executions.
206
+
152
207
  Runtime boundaries inspect only nominal `better-result` values: `Result.err`
153
208
  becomes a failed execution, while a plain object with `status: 'error'` is
154
209
  still a successful value. Intermediate Results do not close the execution
@@ -393,11 +448,12 @@ resolves Services, warms the Runtime, creates Scopes, invokes observers or
393
448
  exposes providers, instances, signals, attributes or backend state. Execution
394
449
  entries remain present until their execution Scope cleanup settles. Warmup is
395
450
  reported as `idle`, `running`, `completed` or `failed`, and `state` reports
396
- `active`, `disposing` or `disposed`.
451
+ `active`, `quiescing`, `draining`, `aborting`, `releasing` or `disposed`.
397
452
 
398
453
  `inspect()` is diagnostic information, not a lock, synchronization primitive or
399
454
  readiness guarantee. It cannot cancel or force shutdown of any execution; use
400
455
  `dispose()` and cooperative `AbortSignal` handling for lifecycle coordination.
456
+ New executions are rejected as soon as quiescing begins.
401
457
 
402
458
  Missing, circular, and provider-construction failures use the logical Service
403
459
  tags in `ServiceNotFoundError`, `CircularDependencyError`, and
@@ -463,6 +519,8 @@ const observer = OpenTelemetryRuntimeObserver.make({
463
519
  // An existing provider can be passed instead with `provider` and `tracerName`.
464
520
  tracer: trace.getTracer('acme.application'),
465
521
  serviceResolution: 'events',
522
+ lifecycle: 'events',
523
+ shutdown: 'events',
466
524
  recordFailures: true,
467
525
  executionAttributeAllowlist: ['requestId'],
468
526
  sanitizeFailure: (cause) =>
@@ -472,6 +530,11 @@ const observer = OpenTelemetryRuntimeObserver.make({
472
530
  const runtime = await Runtime.make(AppLive, { observers: [observer] })
473
531
  ```
474
532
 
533
+ The same observer can be passed to `NodeRuntime.runMain` or
534
+ `NodeRuntime.launch`. Executions started through `runtime.executor.run` and
535
+ `runtime.executor.runWith` use the same execution pipeline and produce one
536
+ execution span each; capturing an executor never creates a separate span.
537
+
475
538
  The adapter starts one span for each execution, keyed only by the Runtime's
476
539
  `executionId`. The Program name is the span name, with
477
540
  `better-effect.execution` as the stable fallback. Executions whose program and
@@ -501,6 +564,27 @@ isolated from every Runtime result. Call `observer.dispose()` when an observer
501
564
  may outlive its Runtime to end state left by malformed or missing end events;
502
565
  normal execution spans are ended exactly once by their matching `executionId`.
503
566
 
567
+ Lifecycle and shutdown telemetry are also explicit and default to `off`:
568
+
569
+ - `lifecycle: 'events'` records activation and release on one
570
+ `better-effect.lifecycle` span; `lifecycle: 'spans'` creates phase spans
571
+ named `better-effect.lifecycle.activate` and
572
+ `better-effect.lifecycle.release`.
573
+ - The public Runtime observer currently exposes lifecycle activation and
574
+ release, but not a per-entry quiesce callback. The adapter does not invent a
575
+ quiesce event; Runtime shutdown quiesce phases remain available through
576
+ `onShutdown`.
577
+ - `shutdown: 'events'` records the public shutdown phases as events on one
578
+ root `better-effect.shutdown` span. `shutdown: 'spans'` keeps that root span
579
+ and adds bounded phase spans for quiesce, drain, abort and release.
580
+ - Shutdown spans use an explicit root parent, never an arbitrary active
581
+ execution span. Shutdown failure marks the root span as `ERROR`; failure
582
+ details are recorded only when `recordFailures: true` and `sanitizeFailure`
583
+ returns them.
584
+
585
+ All lifecycle and shutdown metadata is bounded and excludes resource
586
+ instances, server handles, configuration, payloads and raw causes.
587
+
504
588
  Telemetry is privacy-preserving by default. The adapter records only bounded
505
589
  library, execution ID, Program/outcome, Service-tag and resolution-path data.
506
590
  It does not record causes, stacks, requests, arbitrary execution attributes or
@@ -554,9 +638,9 @@ const cancellableProgram = Effect.fn(async function* () {
554
638
  ```
555
639
 
556
640
  For a Node.js or Bun CLI, use the host-specific `better-effect/node` entrypoint.
557
- `NodeRuntime.runMain` validates its signal and callback options before installing
558
- `SIGINT`/`SIGTERM` listeners, links the first signal to `CurrentAbortSignal`,
559
- and disposes the Runtime exactly once:
641
+ `NodeRuntime.runMain` validates its signal, callback and shutdown options before
642
+ installing `SIGINT`/`SIGTERM` listeners, links the first signal to
643
+ `CurrentAbortSignal`, and disposes the Runtime exactly once:
560
644
 
561
645
  ```ts
562
646
  import { NodeRuntime } from 'better-effect/node'
@@ -571,7 +655,11 @@ await NodeRuntime.runMain(AppLive, main, {
571
655
  console.error(error)
572
656
  return 1
573
657
  },
574
- onSuccess: () => 0
658
+ onSuccess: () => 0,
659
+ shutdown: {
660
+ gracePeriod: 10_000,
661
+ abortAfterGracePeriod: true
662
+ }
575
663
  })
576
664
  ```
577
665
 
@@ -579,11 +667,21 @@ await NodeRuntime.runMain(AppLive, main, {
579
667
  remain rejected and may be reported with `onDefect`. Cleanup-only failures use
580
668
  `onCleanupFailure`, remain observable, and still set a non-zero
581
669
  `process.exitCode` after successful work. The first `SIGINT` or `SIGTERM`
582
- immediately aborts `CurrentAbortSignal`; Runtime disposal then waits
583
- cooperatively for the main execution. Listeners are removed in `finally`,
584
- repeated signals are ignored, and the helper never calls `process.exit()`.
585
- The Node boundary intentionally does not expose a second grace-period policy;
586
- use `Runtime.dispose` directly when a managed Runtime needs one.
670
+ immediately aborts `CurrentAbortSignal`; Runtime disposal then quiesces root
671
+ resources and applies the configured drain policy. Listeners are removed in
672
+ `finally`, repeated signals are ignored, and the helper never calls
673
+ `process.exit()`.
674
+
675
+ For long-lived applications whose startup and shutdown are completely
676
+ represented by a Layer graph, `NodeRuntime.launch` owns the Runtime and waits
677
+ for a process signal or caller `AbortSignal`:
678
+
679
+ ```ts
680
+ await NodeRuntime.launch(AppLive, {
681
+ warmup: true,
682
+ shutdown: { gracePeriod: 10_000, abortAfterGracePeriod: true }
683
+ })
684
+ ```
587
685
 
588
686
  For request-local context or overrides, add a Layer only to that execution:
589
687
 
@@ -609,8 +707,8 @@ import { CurrentRequest } from 'better-effect/standard-services'
609
707
  import { WebEffect } from 'better-effect/web'
610
708
 
611
709
  const handleRequest = (request: Request) =>
612
- WebEffect.handle(
613
- runtime,
710
+ WebEffect.handleWith(
711
+ runtime.executor,
614
712
  request,
615
713
  Effect.fn(async function* () {
616
714
  const currentRequest = yield* CurrentRequest
@@ -627,7 +725,7 @@ const handleRequest = (request: Request) =>
627
725
  )
628
726
  ```
629
727
 
630
- `WebEffect.handle` supplies `CurrentRequest`, forwards `request.signal`, and
728
+ `WebEffect.handleWith` supplies `CurrentRequest`, forwards `request.signal`, and
631
729
  runs one lazy Program in a child Scope. Request-local resources are released
632
730
  before the returned Promise resolves; Runtime-root resources remain owned by
633
731
  the Runtime. A `requestLayer` option can add per-request providers or
@@ -670,6 +768,35 @@ rejected. The Program's Service and failure channels, request-Layer
670
768
  requirements, and override compatibility are checked at the TypeScript
671
769
  boundary.
672
770
 
771
+ For a response whose body is still produced by request-scoped work, opt in to
772
+ the managed streaming boundary with an explicit descriptor. The descriptor's
773
+ metadata is committed immediately, while its lazy producer is pulled with
774
+ backpressure in the same execution context:
775
+
776
+ ```ts
777
+ const response = await WebEffect.streamWith(
778
+ runtime.executor,
779
+ request,
780
+ Effect.fn(async function* () {
781
+ const file = yield* FileService
782
+
783
+ return Result.ok<WebEffect.Stream>({
784
+ status: 200,
785
+ headers: { 'content-type': 'application/octet-stream' },
786
+ producer: async function* ({ signal }) {
787
+ yield* file.bytes({ signal })
788
+ }
789
+ })
790
+ }),
791
+ { unconsumedTimeoutMs: 30_000 }
792
+ )
793
+ ```
794
+
795
+ `WebEffect.streamWith` retains the request execution until EOF, downstream
796
+ cancel/error, request abort, timeout, or Runtime shutdown. It accepts only the
797
+ explicit descriptor—not an arbitrary `Response`—and never maps failures to a
798
+ new status after headers have been committed.
799
+
673
800
  ### Next.js App Router request boundaries
674
801
 
675
802
  The optional `better-effect/next` entrypoint adapts an application-owned
@@ -678,7 +805,7 @@ from the core or main entrypoint:
678
805
 
679
806
  ```ts
680
807
  import { Result } from 'better-result'
681
- import { Effect, Layer, Runtime, Service } from 'better-effect'
808
+ import { Effect, Layer, Service } from 'better-effect'
682
809
  import { NextEffect } from 'better-effect/next'
683
810
 
684
811
  class UserService extends Service<UserService>()('UserService') {
@@ -687,8 +814,7 @@ class UserService extends Service<UserService>()('UserService') {
687
814
  }
688
815
  }
689
816
 
690
- const runtime = await Runtime.make(Layer.make(UserService))
691
- const http = NextEffect.make(runtime)
817
+ const http = NextEffect.managed(Layer.make(UserService))
692
818
 
693
819
  export const GET = http.gen(async function* (_request, context: RouteContext<'/api/users/[id]'>) {
694
820
  const { id } = await context.params
@@ -698,46 +824,67 @@ export const GET = http.gen(async function* (_request, context: RouteContext<'/a
698
824
  })
699
825
  ```
700
826
 
701
- `NextEffect.gen` and `NextEffect.handler` return the native
827
+ Managed builders' `gen` and `handler` methods return the native
702
828
  `(request, context) => Promise<Response>` shape expected by App Router route
703
829
  files. The context is typed with Next's asynchronous `params`; `handler` is
704
830
  useful when the complete `Effect.fn` Program already exists.
705
831
 
706
- For a long-lived App Router application, keep the Runtime in an
707
- application-owned module and expose explicit disposal for the host lifecycle:
832
+ `managed` is lazy and host-owned: each manager creates at most one Runtime,
833
+ shares concurrent first-request initialization, and keeps root Layer resources
834
+ until explicit disposal. It never installs process signal handlers or creates a
835
+ Runtime per request:
708
836
 
709
837
  ```ts
710
838
  // app/runtime.ts
711
- export const appRuntime = await Runtime.make(AppLive)
839
+ export const appNext = NextEffect.managed(AppLive, {
840
+ runtime: { warmup: true }
841
+ })
712
842
 
713
843
  // Call this from the actual host/server shutdown hook.
714
- export const disposeAppRuntime = (): Promise<void> => appRuntime.dispose()
844
+ export const initializeApp = (): Promise<void> => appNext.initialize()
845
+ export const disposeApp = (): Promise<void> => appNext.dispose()
715
846
  ```
716
847
 
717
- Route modules import that Runtime and bind it to the adapter:
848
+ Route modules import the manager directly:
718
849
 
719
850
  ```ts
720
851
  // app/api/users/[id]/route.ts
721
- import { appRuntime } from '@/app/runtime'
852
+ import { appNext } from '@/app/runtime'
722
853
 
723
- const http = NextEffect.make(appRuntime)
854
+ export const GET = appNext.gen(async function* (
855
+ _request,
856
+ context: RouteContext<'/api/users/[id]'>
857
+ ) {
858
+ const { id } = await context.params
859
+ const users = yield* UserService
860
+
861
+ return Result.ok(users.find(id))
862
+ })
724
863
  ```
725
864
 
726
- Do not put this long-lived Runtime in an `await using` scope that ends during
727
- module initialization; that would dispose it before exported handlers serve
728
- requests. `NextEffect.make` stores only the Runtime supplied by the caller: it
729
- does not create a process-global singleton or mutate hidden adapter state.
730
- Sharing `appRuntime` is an application ownership choice. If a deployment
731
- requires per-invocation ownership instead, use the existing `Runtime.use`
732
- helper explicitly; the adapter never creates a Runtime per request by default:
865
+ For code already being built inside a better-effect Runtime, use the inert
866
+ `fromCurrent` builder. Its `handler` and `gen` operations are yieldable, so
867
+ route requirements become requirements of the surrounding Program:
733
868
 
734
869
  ```ts
735
- export const GET = (request, context) =>
736
- Runtime.use(AppLive, (runtime) =>
737
- NextEffect.make(runtime).handler(() => getUser(context))(request, context)
738
- )
870
+ const next = NextEffect.fromCurrent()
871
+
872
+ const makeRoutes = Effect.fn(async function* () {
873
+ const GET = yield* next.gen(async function* (_request, context) {
874
+ const { id } = await context.params
875
+ const users = yield* UserService
876
+ return Result.ok(users.find(id))
877
+ })
878
+
879
+ return Result.ok(GET)
880
+ })
739
881
  ```
740
882
 
883
+ `fromCurrent` does not create or own a Runtime and fails explicitly outside an
884
+ active Runtime execution. `managed` is the App Router/module-scope mode. The
885
+ adapter does not promise automatic HMR cleanup, Edge-runtime compatibility, or
886
+ ownership of workers and other ingress resources in the supplied Layer.
887
+
741
888
  Each request creates one WebEffect execution and child Scope, installs
742
889
  `CurrentRequest` and `CurrentAbortSignal`, and releases request-local Layers
743
890
  before the handler Promise resolves. The request's
@@ -769,14 +916,18 @@ usable without Next.js.
769
916
 
770
917
  ### Bun.serve fetch adapter
771
918
 
772
- The optional `better-effect/bun` entrypoint is a small Bun-only adapter over
773
- `WebEffect`. It does not create a Runtime, own a Bun server, install a router,
774
- or make the Bun server an implicit Service. The route factory receives Bun's
775
- `Request` and `Bun.Server` values explicitly:
919
+ For Bun applications, `better-effect/bun` is Layer-first. `BunEffect.handler`
920
+ is a yieldable operation: it captures the active `Runtime.Executor` while its
921
+ enclosing Layer is acquired, then uses that executor for each request.
922
+
923
+ `BunEffect.server` owns one native `Bun.serve` instance in its `.layer`. The
924
+ server is quiesced during Runtime shutdown, active executions are drained, and
925
+ the same server stop Promise is awaited during release. There is no parallel
926
+ Runtime or import-time server startup:
776
927
 
777
928
  ```ts
778
929
  import { Result } from 'better-result'
779
- import { CurrentAbortSignal, Effect, Layer, Runtime, Service } from 'better-effect'
930
+ import { CurrentAbortSignal, Effect, Layer, Runtime, Service, ServiceRuntime } from 'better-effect'
780
931
  import { CurrentRequest } from 'better-effect/standard-services'
781
932
  import { BunEffect } from 'better-effect/bun'
782
933
 
@@ -786,50 +937,54 @@ class AppService extends Service<AppService>()('AppService') {
786
937
  }
787
938
  }
788
939
 
789
- const AppLive = Layer.make(AppService)
790
- const runtime = await Runtime.make(AppLive)
791
- const http = BunEffect.make(runtime, {
792
- onFailure: (_error, request) =>
793
- Response.json({ error: 'Request failed', url: request.url }, { status: 500 })
794
- })
795
-
796
- const server = Bun.serve({
797
- port: 3000,
798
- fetch: http.handler((request, server) =>
799
- Effect.fn(async function* () {
800
- const app = yield* AppService
801
- const currentRequest = yield* CurrentRequest
802
- const signal = yield* CurrentAbortSignal
803
-
804
- return Result.ok({
805
- value: app.handle(request.url),
806
- currentUrl: (currentRequest.request as Request).url,
807
- port: server.port,
808
- aborted: signal.aborted
940
+ const ApiServer = BunEffect.server('@app/ApiServer', async function* () {
941
+ const fetch = yield* BunEffect.handler(
942
+ {
943
+ onFailure: (_error, request) =>
944
+ Response.json({ error: 'Request failed', url: request.url }, { status: 500 })
945
+ },
946
+ (request, server) =>
947
+ Effect.fn(async function* () {
948
+ const app = yield* AppService
949
+ const currentRequest = yield* CurrentRequest
950
+ const signal = yield* CurrentAbortSignal
951
+
952
+ return Result.ok({
953
+ value: app.handle(request.url),
954
+ currentUrl: (currentRequest.request as Request).url,
955
+ port: server.port,
956
+ aborted: signal.aborted
957
+ })
809
958
  })
810
- })
811
959
  )
960
+
961
+ return { port: 3000, fetch }
812
962
  })
963
+
964
+ const runtime = await Runtime.make(Layer.merge(Layer.make(AppService), ApiServer.layer))
965
+ const server = await runtime.run(() => ServiceRuntime.resolve(ApiServer))
813
966
  ```
814
967
 
815
- `BunEffect.handler` invokes exactly one `WebEffect` request boundary. That
816
- boundary supplies `CurrentRequest`, forwards the request signal, composes
817
- request-local Layers, applies the configured failure/response policies, keeps
818
- thrown defects rejected, and releases request resources before the handler
819
- Promise resolves. Runtime-root resources remain shared and owned by `runtime`.
968
+ `BunEffect.handler` invokes exactly one `WebEffect` request boundary. It
969
+ supplies `CurrentRequest`, forwards the request signal to
970
+ `CurrentAbortSignal`, composes request-local Layers, applies success/failure
971
+ policies, keeps thrown defects rejected, and releases request resources before
972
+ the handler Promise resolves.
820
973
 
821
- The application owns both long-lived resources and must shut them down
822
- explicitly. Stop accepting requests before releasing root Services, then
823
- release the Runtime:
974
+ For an application-chosen server Service, use `BunEffect.layer(service,
975
+ factory)`. Its factory returns `{ options, map }`; the adapter maps the one
976
+ native server into your Service while retaining the same Runtime lifecycle.
977
+ `BunEffect.server` is the convenience form whose Service is the raw
978
+ `Bun.Server<Data>`.
979
+
980
+ Dispose the Runtime once during application shutdown. It stops accepting new
981
+ connections, drains active requests, then releases the server and root Layers;
982
+ repeated disposal calls share one Promise:
824
983
 
825
984
  ```ts
826
- await server.stop()
827
985
  await runtime.dispose()
828
986
  ```
829
987
 
830
- `server.stop()` is the Bun server lifecycle boundary; `BunEffect` does not
831
- provide a `serve` helper or dispose either resource for you. Keep a single
832
- application-owned shutdown Promise if shutdown can be requested more than once.
833
988
  This Bun-specific entrypoint makes no Node compatibility claim.
834
989
 
835
990
  ### Hono request boundaries
@@ -842,28 +997,63 @@ request Scope cleanup to that shared boundary. You only need to configure one
842
997
  Hono middleware boundary per request; the adapter prevents duplicate
843
998
  registrations from opening another execution.
844
999
 
845
- Handlers can yield Services directly; the adapter provides `CurrentRequest`,
846
- forwards `Request.signal`, and converts Results to Responses in one policy:
1000
+ `HonoEffect.app` creates a yieldable application Service and its `.layer`. The
1001
+ factory is acquired once when the application Layer is resolved, so the host
1002
+ owns Runtime composition and shutdown. Handlers can yield Services directly;
1003
+ the adapter provides `CurrentRequest`, forwards `Request.signal`, and converts
1004
+ Results to Responses in one policy:
847
1005
 
848
1006
  ```ts
849
1007
  import { Hono } from 'hono'
1008
+ import { Effect, Layer, Runtime } from 'better-effect'
850
1009
  import { Result } from 'better-result'
851
1010
  import { HonoEffect } from 'better-effect/hono'
852
1011
 
853
- const http = HonoEffect.make(runtime, {
854
- onFailure: (_error, c) => c.json({ error: 'Request failed' }, 400)
855
- })
856
- const app = new Hono()
1012
+ const App = HonoEffect.app(
1013
+ '@app/HonoApp',
1014
+ {
1015
+ onFailure: (_error, c) => c.json({ error: 'Request failed' }, 400)
1016
+ },
1017
+ async function* (http) {
1018
+ const app = new Hono()
1019
+
1020
+ app.use('*', yield* http.middleware())
1021
+ app.get(
1022
+ '/work-orders',
1023
+ yield* http.gen(async function* () {
1024
+ const workOrders = yield* WorkOrderService
1025
+ const items = yield* Result.await(workOrders.list())
1026
+ return Result.ok(items)
1027
+ })
1028
+ )
857
1029
 
858
- app.use('*', http.middleware())
859
- app.get(
860
- '/work-orders',
861
- http.gen(async function* () {
862
- const workOrders = yield* WorkOrderService
863
- const items = yield* Result.await(workOrders.list())
864
- return Result.ok(items)
1030
+ return app
1031
+ }
1032
+ )
1033
+
1034
+ const runtime = await Runtime.make(Layer.merge(AppLive, App.layer))
1035
+ const appResult = await runtime.run(
1036
+ Effect.fn(async function* () {
1037
+ return Result.ok(yield* App)
865
1038
  })
866
1039
  )
1040
+ if (Result.isError(appResult)) throw new Error(String(appResult.error))
1041
+ const app = appResult.value
1042
+ ```
1043
+
1044
+ Route examples below belong inside the `HonoEffect.app` factory. For an
1045
+ existing application Service, use `HonoEffect.layer(ServiceToken, options,
1046
+ factory)` and compose the returned Layer with the Runtime's other Layers.
1047
+
1048
+ Use `HonoEffect.layer` when the application should be provided through an
1049
+ existing Service token instead of a generated one:
1050
+
1051
+ ```ts
1052
+ const AppLive = HonoEffect.layer(MyApplication, {}, async function* (http) {
1053
+ const app = new Hono()
1054
+ app.use('*', yield* http.middleware())
1055
+ return MyApplication.of({ app })
1056
+ })
867
1057
  ```
868
1058
 
869
1059
  One or more Hono validators can precede the generator or handler callback, in
@@ -984,6 +1174,47 @@ low-level provider metadata names.
984
1174
  `Layer.complete(layer)` is a runtime identity that checks a composition root
985
1175
  immediately, so missing Services are reported where the Layer is assembled.
986
1176
 
1177
+ ### Lifecycle-only Layers
1178
+
1179
+ Use `Layer.scopedDiscard` for application components that need startup and
1180
+ shutdown ownership but are not themselves a Service:
1181
+
1182
+ ```ts
1183
+ const PollerLive = Layer.scopedDiscard(
1184
+ () => startPoller(),
1185
+ (poller, outcome) => poller.stop(outcome)
1186
+ )
1187
+
1188
+ const ServerLive = Layer.scopedDiscard(() => startServer(), {
1189
+ quiesce: (server) => server.stopAccepting(),
1190
+ release: (server, outcome) => server.close(outcome)
1191
+ })
1192
+
1193
+ const AppLive = Layer.complete(Layer.merge(ConfigLive, PollerLive))
1194
+ ```
1195
+
1196
+ The acquisition runs once when `Runtime.make` activates the Layer, after
1197
+ provider registration and optional warmup. The release belongs to the Runtime
1198
+ root Scope, runs in reverse activation order, and receives the final
1199
+ `ScopeOutcome`. A contextual acquisition can use `yield*` with
1200
+ `Layer.scopedDiscard` (or the explicit `Layer.scopedDiscardGen`) and retains
1201
+ those Service requirements even though the Layer provides `never`:
1202
+
1203
+ ```ts
1204
+ const PollerLive = Layer.scopedDiscard(
1205
+ async function* () {
1206
+ const config = yield* Config
1207
+ return startPoller(config)
1208
+ },
1209
+ (poller, outcome) => poller.stop(outcome)
1210
+ )
1211
+ ```
1212
+
1213
+ For startup programs, `Layer.effectDiscard` accepts only
1214
+ `Effect.Program<void, never, R>`-compatible programs. A typed failure must be
1215
+ handled by the program before it is made a lifecycle entry; it is not silently
1216
+ converted into a Runtime defect.
1217
+
987
1218
  ---
988
1219
 
989
1220
  ## Why better-effect?
@@ -1230,6 +1461,24 @@ Use `Config.schema(schema)` with `Config.layer(source)` or
1230
1461
  `Config.layerFromEnv(options)` when several descriptors should share an
1231
1462
  explicitly replaceable provider.
1232
1463
 
1464
+ When configuration should be consumed through a Service token, bind the schema
1465
+ to that token so `get` only accepts keys from the decoded schema output:
1466
+
1467
+ ```ts
1468
+ const AppConfig = Config.withSchema(EnvSchema)
1469
+ const AppConfigLive = AppConfig.layerFromEnv({ dotEnvPath: '.env' })
1470
+
1471
+ const program = Effect.fn(async function* () {
1472
+ const config = yield* AppConfig
1473
+ return Result.ok(config.get('DATABASE_URL'))
1474
+ // ^ autocomplete is restricted to EnvSchema output keys
1475
+ })
1476
+ ```
1477
+
1478
+ `config.get(...)` reads the validated schema output, with the value type inferred
1479
+ from the selected key. Use `Config.schema(schema)` or
1480
+ `Config.fromEnv({ schema })` when the complete transformed output is needed.
1481
+
1233
1482
  ---
1234
1483
 
1235
1484
  ## How it compares
@@ -1,6 +1,5 @@
1
- import { N as AnyServiceToken } from "../index-AiKM71-u.mjs";
2
- import { d as LayerBackend, f as LayerBackendDisposeOptions, m as LayerRegistration } from "../outcome-DI8LPIQ4.mjs";
3
- import "../index-DAKn0qHk.mjs";
1
+ import { G as LayerRegistration, H as LayerBackendDisposeOptions, Rt as AnyServiceToken, V as LayerBackend } from "../layer-C2OaqTmB.mjs";
2
+ import "../index-Cy1VsU-u.mjs";
4
3
  //#region src/adapters/iti.d.ts
5
4
  /**
6
5
  * ITI-backed Layer backend.
@@ -1 +1 @@
1
- {"version":3,"file":"iti.d.mts","names":[],"sources":["../../src/adapters/iti.ts"],"mappings":";;;;;;;;;;cAyBa,2BAA2B;UAC9B;mBAES;mBAEA;;;;;;mBAOA;UAET;;EAeR,SAAS,cAAc;;EAwBvB,QAAQ,UAAU,iBAAiB,OAAO,IAAI,aAAa,KAAK,YAAY,aAAa;;EAkCnF,WAAW,UAAU,6BAA6B"}
1
+ {"version":3,"file":"iti.d.mts","names":[],"sources":["../../src/adapters/iti.ts"],"mappings":";;;;;;;;;cAyBa,2BAA2B;UAC9B;mBAES;mBAEA;;;;;;mBAOA;UAET;;EAeR,SAAS,cAAc;;EAwBvB,QAAQ,UAAU,iBAAiB,OAAO,IAAI,aAAa,KAAK,YAAY,aAAa;;EAkCnF,WAAW,UAAU,6BAA6B"}
@@ -1,6 +1,6 @@
1
- import { f as captureServiceTag, n as normalizeLayerRegistration, r as DuplicateServiceError, s as ServiceTagCollisionError, t as captureLayerRegistrationTag, u as ServiceNotFoundError } from "../registration-drnZk0SY.mjs";
1
+ import { f as captureServiceTag, n as normalizeLayerRegistration, r as DuplicateServiceError, s as ServiceTagCollisionError, t as captureLayerRegistrationTag, u as ServiceNotFoundError } from "../registration-Bwe2GbVu.mjs";
2
2
  import { t as isPromiseLike } from "../runtime-CDcCF5cb.mjs";
3
- import { t as assertServiceCompatibility } from "../internal-identity-CbXoZIfw.mjs";
3
+ import { t as assertServiceCompatibility } from "../internal-identity-BuHBtP0k.mjs";
4
4
  import { createContainer } from "iti";
5
5
  //#region src/adapters/iti.ts
6
6
  /**