better-effect 0.12.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 (101) hide show
  1. package/README.md +599 -30
  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 +95 -0
  6. package/dist/bun.d.mts.map +1 -0
  7. package/dist/bun.mjs +177 -0
  8. package/dist/bun.mjs.map +1 -0
  9. package/dist/{context-BUEf1qjL.mjs → context-DSGSqcJj.mjs} +14 -3
  10. package/dist/context-DSGSqcJj.mjs.map +1 -0
  11. package/dist/current-request-Bc1YHL0w.mjs +21 -0
  12. package/dist/current-request-Bc1YHL0w.mjs.map +1 -0
  13. package/dist/current-request-Dm-QQ3Xr.d.mts +22 -0
  14. package/dist/current-request-Dm-QQ3Xr.d.mts.map +1 -0
  15. package/dist/{effect-CK4YZaX0.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 +65 -27
  20. package/dist/hono.d.mts.map +1 -1
  21. package/dist/hono.mjs +177 -53
  22. package/dist/hono.mjs.map +1 -1
  23. package/dist/{index-CX6iAoCB.d.mts → index-3Bkl-sxS.d.mts} +7 -2
  24. package/dist/{index-CX6iAoCB.d.mts.map → index-3Bkl-sxS.d.mts.map} +1 -1
  25. package/dist/{index-B1nH4luq.d.mts → index-C2ed9Ilq.d.mts} +32 -21
  26. package/dist/index-C2ed9Ilq.d.mts.map +1 -0
  27. package/dist/{index-GYJ4qm9K.d.mts → index-ClNndn2-.d.mts} +93 -62
  28. package/dist/index-ClNndn2-.d.mts.map +1 -0
  29. package/dist/{index-BMdU_Z72.d.mts → index-Cy1VsU-u.d.mts} +2 -3
  30. package/dist/index-Cy1VsU-u.d.mts.map +1 -0
  31. package/dist/index.d.mts +6 -7
  32. package/dist/index.mjs +8 -8
  33. package/dist/{internal-identity-CbXoZIfw.mjs → internal-identity-BuHBtP0k.mjs} +2 -2
  34. package/dist/{internal-identity-CbXoZIfw.mjs.map → internal-identity-BuHBtP0k.mjs.map} +1 -1
  35. package/dist/layer-2kjHVv4N.mjs +343 -0
  36. package/dist/layer-2kjHVv4N.mjs.map +1 -0
  37. package/dist/{outcome-CB43bnFW.d.mts → layer-C2OaqTmB.d.mts} +683 -138
  38. package/dist/layer-C2OaqTmB.d.mts.map +1 -0
  39. package/dist/next.d.mts +192 -0
  40. package/dist/next.d.mts.map +1 -0
  41. package/dist/next.mjs +185 -0
  42. package/dist/next.mjs.map +1 -0
  43. package/dist/{node-context-DWsCUBRa.d.mts → node-context-1YEjrU9g.d.mts} +2 -2
  44. package/dist/{node-context-DWsCUBRa.d.mts.map → node-context-1YEjrU9g.d.mts.map} +1 -1
  45. package/dist/{node-context-lggRF4bR.mjs → node-context-g3bCcY9t.mjs} +2 -2
  46. package/dist/{node-context-lggRF4bR.mjs.map → node-context-g3bCcY9t.mjs.map} +1 -1
  47. package/dist/node.d.mts +11 -6
  48. package/dist/node.d.mts.map +1 -1
  49. package/dist/node.mjs +156 -10
  50. package/dist/node.mjs.map +1 -1
  51. package/dist/opentelemetry.d.mts +112 -0
  52. package/dist/opentelemetry.d.mts.map +1 -0
  53. package/dist/opentelemetry.mjs +871 -0
  54. package/dist/opentelemetry.mjs.map +1 -0
  55. package/dist/{registration-drnZk0SY.mjs → registration-Bwe2GbVu.mjs} +8 -2
  56. package/dist/registration-Bwe2GbVu.mjs.map +1 -0
  57. package/dist/runtime/explicit.d.mts +2 -2
  58. package/dist/runtime/explicit.mjs +1 -1
  59. package/dist/runtime/node.d.mts +3 -3
  60. package/dist/runtime/node.mjs +2 -2
  61. package/dist/runtime-qpcVwaz_.mjs +1680 -0
  62. package/dist/runtime-qpcVwaz_.mjs.map +1 -0
  63. package/dist/service-CO9VJQYa.mjs +74 -0
  64. package/dist/service-CO9VJQYa.mjs.map +1 -0
  65. package/dist/signal-5Rg9bUUe.mjs +52 -0
  66. package/dist/signal-5Rg9bUUe.mjs.map +1 -0
  67. package/dist/{standard-services-GBZ32tll.mjs → standard-services-B-iMM_W7.mjs} +39 -19
  68. package/dist/standard-services-B-iMM_W7.mjs.map +1 -0
  69. package/dist/standard-services.d.mts +4 -3
  70. package/dist/standard-services.mjs +3 -2
  71. package/dist/testing.d.mts +25 -6
  72. package/dist/testing.d.mts.map +1 -1
  73. package/dist/testing.mjs +51 -7
  74. package/dist/testing.mjs.map +1 -1
  75. package/dist/types-BFTqCCcw.d.mts +81 -0
  76. package/dist/types-BFTqCCcw.d.mts.map +1 -0
  77. package/dist/web-effect-B9hp9jIe.mjs +380 -0
  78. package/dist/web-effect-B9hp9jIe.mjs.map +1 -0
  79. package/dist/web.d.mts +56 -0
  80. package/dist/web.d.mts.map +1 -0
  81. package/dist/web.mjs +2 -0
  82. package/package.json +36 -11
  83. package/dist/context-BUEf1qjL.mjs.map +0 -1
  84. package/dist/effect-CK4YZaX0.mjs.map +0 -1
  85. package/dist/errors-BKcsQ0ip.d.mts.map +0 -1
  86. package/dist/index-B1nH4luq.d.mts.map +0 -1
  87. package/dist/index-BMdU_Z72.d.mts.map +0 -1
  88. package/dist/index-GYJ4qm9K.d.mts.map +0 -1
  89. package/dist/index-heuaRmXR.d.mts +0 -374
  90. package/dist/index-heuaRmXR.d.mts.map +0 -1
  91. package/dist/layer-D56fRdLt.mjs +0 -269
  92. package/dist/layer-D56fRdLt.mjs.map +0 -1
  93. package/dist/outcome-CB43bnFW.d.mts.map +0 -1
  94. package/dist/program-metadata-DhQCCirO.mjs +0 -260
  95. package/dist/program-metadata-DhQCCirO.mjs.map +0 -1
  96. package/dist/registration-drnZk0SY.mjs.map +0 -1
  97. package/dist/runtime-CBMrX8Wl.mjs +0 -776
  98. package/dist/runtime-CBMrX8Wl.mjs.map +0 -1
  99. package/dist/signal-D2UTcaaG.mjs +0 -105
  100. package/dist/signal-D2UTcaaG.mjs.map +0 -1
  101. package/dist/standard-services-GBZ32tll.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
@@ -344,9 +399,13 @@ await runtime.warmup()
344
399
  Warmup failures include the Service and resolution path, release resources
345
400
  already acquired, dispose the backend and reject the Runtime. Optional
346
401
  observers expose Service resolution/acquisition, execution and Layer release
347
- events without coupling the core to an observability SDK. Execution events
348
- carry one matching `executionId`, the optional Program name, copied readonly
349
- attributes, and a monotonic `durationMs` measured through execution cleanup:
402
+ events without coupling the core to an observability SDK. Service events carry
403
+ an optional matching `executionId` only when they occur inside an active
404
+ execution; warmup and Runtime-root activity omit it. Execution events carry one
405
+ matching `executionId`, the optional Program name, copied readonly attributes,
406
+ and a monotonic `durationMs` measured through execution cleanup. An end event
407
+ keeps the primary program `outcome` and adds `cleanupFailure` when execution
408
+ Scope cleanup fails:
350
409
 
351
410
  ```ts
352
411
  const runtime = await Runtime.make(AppLive, {
@@ -389,11 +448,12 @@ resolves Services, warms the Runtime, creates Scopes, invokes observers or
389
448
  exposes providers, instances, signals, attributes or backend state. Execution
390
449
  entries remain present until their execution Scope cleanup settles. Warmup is
391
450
  reported as `idle`, `running`, `completed` or `failed`, and `state` reports
392
- `active`, `disposing` or `disposed`.
451
+ `active`, `quiescing`, `draining`, `aborting`, `releasing` or `disposed`.
393
452
 
394
453
  `inspect()` is diagnostic information, not a lock, synchronization primitive or
395
454
  readiness guarantee. It cannot cancel or force shutdown of any execution; use
396
455
  `dispose()` and cooperative `AbortSignal` handling for lifecycle coordination.
456
+ New executions are rejected as soon as quiescing begins.
397
457
 
398
458
  Missing, circular, and provider-construction failures use the logical Service
399
459
  tags in `ServiceNotFoundError`, `CircularDependencyError`, and
@@ -438,6 +498,108 @@ and its ordered `timeline`; call `clear()` to reuse it. Composition invokes
438
498
  observers in declaration order and isolates thrown or rejected observer
439
499
  failures from the Runtime result.
440
500
 
501
+ ### Optional OpenTelemetry tracing
502
+
503
+ Install the OpenTelemetry API only when this subpath is used:
504
+
505
+ ```bash
506
+ bun add better-effect @opentelemetry/api
507
+ ```
508
+
509
+ `better-effect/opentelemetry` accepts either an existing `Tracer` or an existing
510
+ `TracerProvider`. Its supported `@opentelemetry/api` peer range is
511
+ `>=1.9.0 <1.10.0` (tested with 1.9.1). It never installs a global provider, SDK,
512
+ context manager or exporter. The provider form only calls `getTracer`:
513
+
514
+ ```ts
515
+ import { trace } from '@opentelemetry/api'
516
+ import { OpenTelemetryRuntimeObserver } from 'better-effect/opentelemetry'
517
+
518
+ const observer = OpenTelemetryRuntimeObserver.make({
519
+ // An existing provider can be passed instead with `provider` and `tracerName`.
520
+ tracer: trace.getTracer('acme.application'),
521
+ serviceResolution: 'events',
522
+ lifecycle: 'events',
523
+ shutdown: 'events',
524
+ recordFailures: true,
525
+ executionAttributeAllowlist: ['requestId'],
526
+ sanitizeFailure: (cause) =>
527
+ cause instanceof KnownDomainError ? { message: cause.code } : undefined
528
+ })
529
+
530
+ const runtime = await Runtime.make(AppLive, { observers: [observer] })
531
+ ```
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
+
538
+ The adapter starts one span for each execution, keyed only by the Runtime's
539
+ `executionId`. The Program name is the span name, with
540
+ `better-effect.execution` as the stable fallback. Executions whose program and
541
+ execution cleanup succeed use OpenTelemetry `OK`; typed `Result.err` values,
542
+ thrown defects and execution cleanup failures use `ERROR`. The Runtime observer
543
+ keeps cleanup failure separate from the primary program outcome, so the adapter
544
+ can report the final execution status without turning cleanup into a program
545
+ defect or serializing its cause by default.
546
+ Span timing is supplied by OpenTelemetry's start/end clock; `durationMs` is not
547
+ written as a second duration source.
548
+
549
+ Service telemetry is explicit and defaults to `off`:
550
+
551
+ - `off` creates no Service telemetry.
552
+ - `events` adds resolution, acquisition and release events to an execution span
553
+ only when the Runtime event carries an explicit active execution ID. Warmup,
554
+ Runtime-root cleanup and other events without an owner use standalone spans.
555
+ - `spans` creates child Service spans only for events with an explicit active
556
+ execution ID. Warmup and Runtime-root cleanup are represented by standalone
557
+ service spans (or generic event-carrier spans in `events` mode); ambient
558
+ OpenTelemetry context is never used to guess ownership.
559
+
560
+ Service identity is always the logical `serviceTag`; constructor names and
561
+ Service instances are never used. `RuntimeObserver.compose` can combine this
562
+ adapter with recorded, graph and application observers, and a tracer failure is
563
+ isolated from every Runtime result. Call `observer.dispose()` when an observer
564
+ may outlive its Runtime to end state left by malformed or missing end events;
565
+ normal execution spans are ended exactly once by their matching `executionId`.
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
+
588
+ Telemetry is privacy-preserving by default. The adapter records only bounded
589
+ library, execution ID, Program/outcome, Service-tag and resolution-path data.
590
+ It does not record causes, stacks, requests, arbitrary execution attributes or
591
+ Service instances, and it never stringifies unknown values. Add attributes only
592
+ through `executionAttributeAllowlist` or the explicit
593
+ `sanitizeExecutionAttributes` callback; add failure details only through
594
+ `sanitizeFailure` together with `recordFailures: true`. Unsupported values are
595
+ dropped, strings are bounded, and default limits are 256 characters, 32
596
+ attributes and 16 resolution-path tags (caller limits remain capped). Sanitizer
597
+ callbacks should return only intentional scalar OpenTelemetry attributes.
598
+
599
+ For distributed tracing, establish the caller's OpenTelemetry context before
600
+ calling `runtime.run`; the adapter does not propagate context or instrument
601
+ HTTP/database libraries automatically.
602
+
441
603
  For a startup view of the graph actually observed by a Runtime, compose the
442
604
  small graph observer and warm the Layer before accepting work:
443
605
 
@@ -476,9 +638,9 @@ const cancellableProgram = Effect.fn(async function* () {
476
638
  ```
477
639
 
478
640
  For a Node.js or Bun CLI, use the host-specific `better-effect/node` entrypoint.
479
- `NodeRuntime.runMain` validates its signal and callback options before installing
480
- `SIGINT`/`SIGTERM` listeners, links the first signal to `CurrentAbortSignal`,
481
- 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:
482
644
 
483
645
  ```ts
484
646
  import { NodeRuntime } from 'better-effect/node'
@@ -493,7 +655,11 @@ await NodeRuntime.runMain(AppLive, main, {
493
655
  console.error(error)
494
656
  return 1
495
657
  },
496
- onSuccess: () => 0
658
+ onSuccess: () => 0,
659
+ shutdown: {
660
+ gracePeriod: 10_000,
661
+ abortAfterGracePeriod: true
662
+ }
497
663
  })
498
664
  ```
499
665
 
@@ -501,11 +667,21 @@ await NodeRuntime.runMain(AppLive, main, {
501
667
  remain rejected and may be reported with `onDefect`. Cleanup-only failures use
502
668
  `onCleanupFailure`, remain observable, and still set a non-zero
503
669
  `process.exitCode` after successful work. The first `SIGINT` or `SIGTERM`
504
- immediately aborts `CurrentAbortSignal`; Runtime disposal then waits
505
- cooperatively for the main execution. Listeners are removed in `finally`,
506
- repeated signals are ignored, and the helper never calls `process.exit()`.
507
- The Node boundary intentionally does not expose a second grace-period policy;
508
- 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
+ ```
509
685
 
510
686
  For request-local context or overrides, add a Layer only to that execution:
511
687
 
@@ -518,32 +694,366 @@ with the execution Scope and never change the shared Runtime environment. Its
518
694
  external requirements must be provided by the Runtime, and the Hono failure
519
695
  handler/request-Layer types are checked at the adapter boundary.
520
696
 
697
+ ### Framework-neutral Web request boundaries
698
+
699
+ The `better-effect/web` entrypoint provides a small Request-to-Response
700
+ boundary without depending on Hono or another framework. Use it anywhere a
701
+ framework gives you standard Web `Request` and `Response` values:
702
+
703
+ ```ts
704
+ import { Result } from 'better-result'
705
+ import { CurrentAbortSignal, Effect } from 'better-effect'
706
+ import { CurrentRequest } from 'better-effect/standard-services'
707
+ import { WebEffect } from 'better-effect/web'
708
+
709
+ const handleRequest = (request: Request) =>
710
+ WebEffect.handleWith(
711
+ runtime.executor,
712
+ request,
713
+ Effect.fn(async function* () {
714
+ const currentRequest = yield* CurrentRequest
715
+ const signal = yield* CurrentAbortSignal
716
+ const user = yield* UserService
717
+ const result = yield* Result.await(user.find())
718
+
719
+ return Result.ok({
720
+ result,
721
+ url: (currentRequest.request as Request).url,
722
+ aborted: signal.aborted
723
+ })
724
+ })
725
+ )
726
+ ```
727
+
728
+ `WebEffect.handleWith` supplies `CurrentRequest`, forwards `request.signal`, and
729
+ runs one lazy Program in a child Scope. Request-local resources are released
730
+ before the returned Promise resolves; Runtime-root resources remain owned by
731
+ the Runtime. A `requestLayer` option can add per-request providers or
732
+ intentionally override a compatible request tag.
733
+
734
+ The default success policy passes through a Web `Response`, maps top-level
735
+ `undefined` to 204, and wraps supported values as `{ data: value }` JSON. A
736
+ non-`undefined`, non-`Response` value must be an acyclic graph of `null`,
737
+ booleans, strings, finite numbers, dense arrays, and plain object records.
738
+ Arrays must have only the own string properties `length` and one property for
739
+ each index from `0` through `length - 1`; each index must be an enumerable data
740
+ property. Symbol keys, extra own properties (including non-enumerable ones),
741
+ sparse holes, and accessor elements are rejected. Object records must have
742
+ `Object.prototype` or `null` as their prototype, and every own string-keyed
743
+ property must be an enumerable data property. Own symbol keys,
744
+ non-enumerable properties, accessors, custom prototypes, and other non-plain
745
+ objects are rejected. Shared references in separate branches are serialized as
746
+ repeated values. Nested `undefined`, `bigint`, functions, symbols, and
747
+ non-finite numbers (`NaN` and infinities) are rejected with
748
+ `WebEffectSerializationError` (a `TypeError`) instead of being silently dropped
749
+ or coerced. Use an explicit `onSuccess` policy for other representations. The
750
+ default failure policy passes through a standards-compatible `Response`
751
+ failure and redacts every other typed failure to `{ error: 'Internal Server Error' }`
752
+ with status 500. Custom
753
+ `onSuccess`/`onFailure` policies may be asynchronous but must return a
754
+ standards-compatible `Response`. The boundary checks that protocol structurally:
755
+ `status` is an integer in `0` or `200` through `599`, `ok` matches the 2xx
756
+ status range, `redirected` and `bodyUsed` are booleans, `statusText` and `url`
757
+ are strings, `type` is a standard Response type, and
758
+ `arrayBuffer()`, `blob()`, `bytes()`, `clone()`, `formData()`, `json()`, and
759
+ `text()` are callable. `headers` must provide callable `append()`, `delete()`,
760
+ `get()`, `getSetCookie()`, `has()`, `set()`, and `forEach()` operations. `body`
761
+ must be `null` (including legitimate null-body Responses such as `204`) or a
762
+ ReadableStream-compatible object with a boolean `locked` property and callable
763
+ `cancel()`, `getReader()`, `pipeThrough()`, `pipeTo()`, and `tee()` methods.
764
+ Native cross-realm Responses and other values satisfying this protocol are
765
+ accepted without `instanceof Response`; missing capabilities and forged
766
+ `Response.prototype` values fail with `TypeError`. Thrown defects remain
767
+ rejected. The Program's Service and failure channels, request-Layer
768
+ requirements, and override compatibility are checked at the TypeScript
769
+ boundary.
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
+
800
+ ### Next.js App Router request boundaries
801
+
802
+ The optional `better-effect/next` entrypoint adapts an application-owned
803
+ Runtime to native Next.js App Router Route Handlers without importing Next.js
804
+ from the core or main entrypoint:
805
+
806
+ ```ts
807
+ import { Result } from 'better-result'
808
+ import { Effect, Layer, Service } from 'better-effect'
809
+ import { NextEffect } from 'better-effect/next'
810
+
811
+ class UserService extends Service<UserService>()('UserService') {
812
+ find(id: string) {
813
+ return { id, name: 'Ada' }
814
+ }
815
+ }
816
+
817
+ const http = NextEffect.managed(Layer.make(UserService))
818
+
819
+ export const GET = http.gen(async function* (_request, context: RouteContext<'/api/users/[id]'>) {
820
+ const { id } = await context.params
821
+ const users = yield* UserService
822
+
823
+ return Result.ok(users.find(id))
824
+ })
825
+ ```
826
+
827
+ Managed builders' `gen` and `handler` methods return the native
828
+ `(request, context) => Promise<Response>` shape expected by App Router route
829
+ files. The context is typed with Next's asynchronous `params`; `handler` is
830
+ useful when the complete `Effect.fn` Program already exists.
831
+
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:
836
+
837
+ ```ts
838
+ // app/runtime.ts
839
+ export const appNext = NextEffect.managed(AppLive, {
840
+ runtime: { warmup: true }
841
+ })
842
+
843
+ // Call this from the actual host/server shutdown hook.
844
+ export const initializeApp = (): Promise<void> => appNext.initialize()
845
+ export const disposeApp = (): Promise<void> => appNext.dispose()
846
+ ```
847
+
848
+ Route modules import the manager directly:
849
+
850
+ ```ts
851
+ // app/api/users/[id]/route.ts
852
+ import { appNext } from '@/app/runtime'
853
+
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
+ })
863
+ ```
864
+
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:
868
+
869
+ ```ts
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
+ })
881
+ ```
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
+
888
+ Each request creates one WebEffect execution and child Scope, installs
889
+ `CurrentRequest` and `CurrentAbortSignal`, and releases request-local Layers
890
+ before the handler Promise resolves. The request's
891
+ `AbortSignal` is linked into the execution signal, and concurrent requests keep
892
+ request Layers and context isolated.
893
+
894
+ The default success policy passes through a returned `Response`; other values
895
+ are encoded as `{ data: value }` JSON using the same strict serialization rules
896
+ as `better-effect/web`. A route may select at most one success policy:
897
+ `respond` handles a complete Response, `serialize` transforms a JSON-safe
898
+ value, and route-level `onSuccess` replaces the shared success policy. Shared
899
+ `onSuccess` and `onFailure` policies receive the native Request and route
900
+ context. Typed
901
+ `Result.err` failures use `onFailure` (or the redacted 500 default), while
902
+ thrown defects remain rejected. Policies must return a standards-compatible
903
+ `Response` and may be asynchronous. This adapter does not add Edge-runtime
904
+ support; use it only in hosts supported by the configured better-effect
905
+ Runtime.
906
+
907
+ Install Next.js only when this subpath is used:
908
+
909
+ ```bash
910
+ bun add better-effect better-result next
911
+ ```
912
+
913
+ `next` is an optional peer dependency, and `better-effect/next` uses only the
914
+ public native Route Handler shape, so the core and other entrypoints remain
915
+ usable without Next.js.
916
+
917
+ ### Bun.serve fetch adapter
918
+
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:
927
+
928
+ ```ts
929
+ import { Result } from 'better-result'
930
+ import { CurrentAbortSignal, Effect, Layer, Runtime, Service, ServiceRuntime } from 'better-effect'
931
+ import { CurrentRequest } from 'better-effect/standard-services'
932
+ import { BunEffect } from 'better-effect/bun'
933
+
934
+ class AppService extends Service<AppService>()('AppService') {
935
+ handle(url: string) {
936
+ return url
937
+ }
938
+ }
939
+
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
+ })
958
+ })
959
+ )
960
+
961
+ return { port: 3000, fetch }
962
+ })
963
+
964
+ const runtime = await Runtime.make(Layer.merge(Layer.make(AppService), ApiServer.layer))
965
+ const server = await runtime.run(() => ServiceRuntime.resolve(ApiServer))
966
+ ```
967
+
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.
973
+
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:
983
+
984
+ ```ts
985
+ await runtime.dispose()
986
+ ```
987
+
988
+ This Bun-specific entrypoint makes no Node compatibility claim.
989
+
521
990
  ### Hono request boundaries
522
991
 
523
- The optional `better-effect/hono` entrypoint runs one Runtime execution and
524
- Scope around each request. Handlers can yield Services directly; the adapter
525
- provides `CurrentRequest`, forwards `Request.signal`, and converts Results to
526
- Responses in one policy:
992
+ The optional `better-effect/hono` entrypoint supplies Hono's middleware,
993
+ validator, and Context integration around the same Web request boundary used
994
+ by `better-effect/web`. It adapts Hono's Context to a standard Web `Request`,
995
+ then delegates the single `Runtime` execution, Result/defect handling, and
996
+ request Scope cleanup to that shared boundary. You only need to configure one
997
+ Hono middleware boundary per request; the adapter prevents duplicate
998
+ registrations from opening another execution.
999
+
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:
527
1005
 
528
1006
  ```ts
529
1007
  import { Hono } from 'hono'
1008
+ import { Effect, Layer, Runtime } from 'better-effect'
530
1009
  import { Result } from 'better-result'
531
1010
  import { HonoEffect } from 'better-effect/hono'
532
1011
 
533
- const http = HonoEffect.make(runtime, {
534
- onFailure: (_error, c) => c.json({ error: 'Request failed' }, 400)
535
- })
536
- 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
+ )
1029
+
1030
+ return app
1031
+ }
1032
+ )
537
1033
 
538
- app.use('*', http.middleware())
539
- app.get(
540
- '/work-orders',
541
- http.gen(async function* () {
542
- const workOrders = yield* WorkOrderService
543
- const items = yield* Result.await(workOrders.list())
544
- return Result.ok(items)
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)
545
1038
  })
546
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
+ })
547
1057
  ```
548
1058
 
549
1059
  One or more Hono validators can precede the generator or handler callback, in
@@ -664,6 +1174,47 @@ low-level provider metadata names.
664
1174
  `Layer.complete(layer)` is a runtime identity that checks a composition root
665
1175
  immediately, so missing Services are reported where the Layer is assembled.
666
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
+
667
1218
  ---
668
1219
 
669
1220
  ## Why better-effect?
@@ -910,6 +1461,24 @@ Use `Config.schema(schema)` with `Config.layer(source)` or
910
1461
  `Config.layerFromEnv(options)` when several descriptors should share an
911
1462
  explicitly replaceable provider.
912
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
+
913
1482
  ---
914
1483
 
915
1484
  ## How it compares