better-effect 0.11.0 → 0.13.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 (102) hide show
  1. package/README.md +544 -10
  2. package/dist/adapters/iti.d.mts +3 -2
  3. package/dist/adapters/iti.d.mts.map +1 -1
  4. package/dist/adapters/iti.mjs +11 -10
  5. package/dist/adapters/iti.mjs.map +1 -1
  6. package/dist/bun.d.mts +43 -0
  7. package/dist/bun.d.mts.map +1 -0
  8. package/dist/bun.mjs +40 -0
  9. package/dist/bun.mjs.map +1 -0
  10. package/dist/{context-BUEf1qjL.mjs → context-BphfFUMO.mjs} +4 -2
  11. package/dist/context-BphfFUMO.mjs.map +1 -0
  12. package/dist/current-request-CIU7AHhF.d.mts +22 -0
  13. package/dist/current-request-CIU7AHhF.d.mts.map +1 -0
  14. package/dist/current-request-DDKNyymv.mjs +20 -0
  15. package/dist/current-request-DDKNyymv.mjs.map +1 -0
  16. package/dist/disposable-CP-Q2H3l.mjs +57 -0
  17. package/dist/disposable-CP-Q2H3l.mjs.map +1 -0
  18. package/dist/{effect-2ZcGZI8A.mjs → effect-CPdV7Yv8.mjs} +56 -10
  19. package/dist/effect-CPdV7Yv8.mjs.map +1 -0
  20. package/dist/hono.d.mts +7 -5
  21. package/dist/hono.d.mts.map +1 -1
  22. package/dist/hono.mjs +88 -30
  23. package/dist/hono.mjs.map +1 -1
  24. package/dist/{index-heuaRmXR.d.mts → index-AiKM71-u.d.mts} +3 -1
  25. package/dist/{index-heuaRmXR.d.mts.map → index-AiKM71-u.d.mts.map} +1 -1
  26. package/dist/index-BodMy6rO.d.mts +180 -0
  27. package/dist/index-BodMy6rO.d.mts.map +1 -0
  28. package/dist/{index-C7Qild0_.d.mts → index-CCi07kUw.d.mts} +15 -151
  29. package/dist/index-CCi07kUw.d.mts.map +1 -0
  30. package/dist/index-DAKn0qHk.d.mts +19 -0
  31. package/dist/index-DAKn0qHk.d.mts.map +1 -0
  32. package/dist/{index-DStz0JMN.d.mts → index-cXgyMa0v.d.mts} +75 -22
  33. package/dist/index-cXgyMa0v.d.mts.map +1 -0
  34. package/dist/index.d.mts +7 -4
  35. package/dist/index.mjs +8 -6
  36. package/dist/index.mjs.map +1 -1
  37. package/dist/{internal-identity-DmUpBeeL.mjs → internal-identity-CbXoZIfw.mjs} +3 -3
  38. package/dist/internal-identity-CbXoZIfw.mjs.map +1 -0
  39. package/dist/{signal-B97cs85Z.mjs → layer-D16GKxuX.mjs} +31 -112
  40. package/dist/layer-D16GKxuX.mjs.map +1 -0
  41. package/dist/next.d.mts +116 -0
  42. package/dist/next.d.mts.map +1 -0
  43. package/dist/next.mjs +68 -0
  44. package/dist/next.mjs.map +1 -0
  45. package/dist/node-context-Ca18oNSg.mjs +25 -0
  46. package/dist/node-context-Ca18oNSg.mjs.map +1 -0
  47. package/dist/node-context-DczjAcrX.d.mts +12 -0
  48. package/dist/node-context-DczjAcrX.d.mts.map +1 -0
  49. package/dist/node.d.mts +51 -0
  50. package/dist/node.d.mts.map +1 -0
  51. package/dist/node.mjs +233 -0
  52. package/dist/node.mjs.map +1 -0
  53. package/dist/observer-9nhbL7Pr.d.mts +68 -0
  54. package/dist/observer-9nhbL7Pr.d.mts.map +1 -0
  55. package/dist/opentelemetry.d.mts +94 -0
  56. package/dist/opentelemetry.d.mts.map +1 -0
  57. package/dist/opentelemetry.mjs +453 -0
  58. package/dist/opentelemetry.mjs.map +1 -0
  59. package/dist/{index-rQhZk3Nt.d.mts → outcome-DI8LPIQ4.d.mts} +27 -72
  60. package/dist/outcome-DI8LPIQ4.d.mts.map +1 -0
  61. package/dist/{scope-GGnmTQck.mjs → program-metadata-BxjGkT17.mjs} +19 -4
  62. package/dist/program-metadata-BxjGkT17.mjs.map +1 -0
  63. package/dist/registration-drnZk0SY.mjs +182 -0
  64. package/dist/registration-drnZk0SY.mjs.map +1 -0
  65. package/dist/runtime/explicit.d.mts +1 -1
  66. package/dist/runtime/explicit.mjs +1 -1
  67. package/dist/runtime/node.d.mts +3 -12
  68. package/dist/runtime/node.mjs +2 -24
  69. package/dist/{runtime-DnMn0X0X.mjs → runtime-C_55ofnL.mjs} +245 -67
  70. package/dist/runtime-C_55ofnL.mjs.map +1 -0
  71. package/dist/signal-nfgFyNCk.mjs +52 -0
  72. package/dist/signal-nfgFyNCk.mjs.map +1 -0
  73. package/dist/{standard-services-BFBq-4lo.mjs → standard-services-vnGIafkS.mjs} +200 -30
  74. package/dist/standard-services-vnGIafkS.mjs.map +1 -0
  75. package/dist/standard-services.d.mts +4 -3
  76. package/dist/standard-services.mjs +4 -3
  77. package/dist/testing.d.mts +67 -7
  78. package/dist/testing.d.mts.map +1 -1
  79. package/dist/testing.mjs +192 -9
  80. package/dist/testing.mjs.map +1 -1
  81. package/dist/types-oihI-ESv.d.mts +54 -0
  82. package/dist/types-oihI-ESv.d.mts.map +1 -0
  83. package/dist/web-Bq3MfMrj.mjs +147 -0
  84. package/dist/web-Bq3MfMrj.mjs.map +1 -0
  85. package/dist/web.d.mts +47 -0
  86. package/dist/web.d.mts.map +1 -0
  87. package/dist/web.mjs +2 -0
  88. package/package.json +39 -3
  89. package/dist/context-BUEf1qjL.mjs.map +0 -1
  90. package/dist/effect-2ZcGZI8A.mjs.map +0 -1
  91. package/dist/errors-Dnjhzbt0.mjs +0 -99
  92. package/dist/errors-Dnjhzbt0.mjs.map +0 -1
  93. package/dist/index-C7Qild0_.d.mts.map +0 -1
  94. package/dist/index-DStz0JMN.d.mts.map +0 -1
  95. package/dist/index-rQhZk3Nt.d.mts.map +0 -1
  96. package/dist/internal-identity-DmUpBeeL.mjs.map +0 -1
  97. package/dist/runtime/node.d.mts.map +0 -1
  98. package/dist/runtime/node.mjs.map +0 -1
  99. package/dist/runtime-DnMn0X0X.mjs.map +0 -1
  100. package/dist/scope-GGnmTQck.mjs.map +0 -1
  101. package/dist/signal-B97cs85Z.mjs.map +0 -1
  102. package/dist/standard-services-BFBq-4lo.mjs.map +0 -1
package/README.md CHANGED
@@ -12,7 +12,9 @@ bun add better-effect better-result
12
12
 
13
13
  The published Runtime entrypoint is officially supported on Node.js and Bun.
14
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.
15
+ context uses Node/Bun async context propagation. `bun run check` also deletes
16
+ and rebuilds `dist`, packs the result into a temporary consumer, and runs the
17
+ full Node/Bun `NodeRuntime` child-process suite.
16
18
 
17
19
  ## TypeScript knows what your application needs
18
20
 
@@ -156,6 +158,28 @@ Scope.
156
158
  Scope. `Effect.fn` captures the generator as a lazy `Program` for Runtime
157
159
  boundaries; the callback form remains supported for compatibility.
158
160
 
161
+ Name a Program without executing it. The name is private diagnostic metadata,
162
+ so the value remains an ordinary callable function and can be used with `pipe`:
163
+
164
+ ```ts
165
+ const loadUser = pipe(
166
+ Effect.fn(async function* () {
167
+ return Result.ok(await userRepository.load(userId))
168
+ }),
169
+ Program.named('user.load')
170
+ )
171
+
172
+ const result = await runtime.run(loadUser, {
173
+ attributes: { userId, requestId }
174
+ })
175
+ ```
176
+
177
+ `Program.map`, `mapError`, `andThen`, `tap`, `tapError`, and `recover` preserve
178
+ their source name. A later `Program.named` call overrides it. Collection
179
+ Programs do not concatenate child names; give `Program.all`, `Program.forEach`,
180
+ or `Program.allResults` an optional `{ name }` when the collection itself needs
181
+ a diagnostic name.
182
+
159
183
  `Program.all` keeps a collection lazy until the returned Program is run. Pass
160
184
  `{ concurrency: n }` for a positive bounded FIFO worker pool; values retain
161
185
  input order. If a Program returns an error or throws, scheduling stops, already-
@@ -320,7 +344,60 @@ await runtime.warmup()
320
344
  Warmup failures include the Service and resolution path, release resources
321
345
  already acquired, dispose the backend and reject the Runtime. Optional
322
346
  observers expose Service resolution/acquisition, execution and Layer release
323
- events without coupling the core to an observability SDK:
347
+ events without coupling the core to an observability SDK. Service events carry
348
+ an optional matching `executionId` only when they occur inside an active
349
+ execution; warmup and Runtime-root activity omit it. Execution events carry one
350
+ matching `executionId`, the optional Program name, copied readonly attributes,
351
+ and a monotonic `durationMs` measured through execution cleanup. An end event
352
+ keeps the primary program `outcome` and adds `cleanupFailure` when execution
353
+ Scope cleanup fails:
354
+
355
+ ```ts
356
+ const runtime = await Runtime.make(AppLive, {
357
+ observers: [
358
+ {
359
+ onExecutionStart: ({ executionId, name, attributes }) =>
360
+ console.debug('program.start', { executionId, name, attributes }),
361
+ onExecutionEnd: ({ executionId, outcome, durationMs }) =>
362
+ metrics.observe('program.duration_ms', durationMs, {
363
+ executionId,
364
+ outcome: outcome.status
365
+ })
366
+ }
367
+ ]
368
+ })
369
+ ```
370
+
371
+ The Runtime does not serialize or inspect attribute values. Attributes are
372
+ shallow-copied and exposed as a readonly event view; do not attach secrets,
373
+ large objects, or mutable application state. Keep sensitive values out of
374
+ observer logs and metrics labels.
375
+
376
+ For coarse, synchronous diagnostics, use `runtime.inspect()`:
377
+
378
+ ```ts
379
+ const inspection = runtime.inspect()
380
+ // {
381
+ // state: 'active',
382
+ // warmup: 'idle',
383
+ // activeExecutions: 0,
384
+ // executions: [],
385
+ // services: ['Database', 'UserRepository'],
386
+ // shutdownSignalAborted: false
387
+ // }
388
+ ```
389
+
390
+ The returned snapshot and its arrays are detached and immutable. It contains
391
+ only public Service tags and execution IDs, names and start timestamps; it never
392
+ resolves Services, warms the Runtime, creates Scopes, invokes observers or
393
+ exposes providers, instances, signals, attributes or backend state. Execution
394
+ entries remain present until their execution Scope cleanup settles. Warmup is
395
+ reported as `idle`, `running`, `completed` or `failed`, and `state` reports
396
+ `active`, `disposing` or `disposed`.
397
+
398
+ `inspect()` is diagnostic information, not a lock, synchronization primitive or
399
+ readiness guarantee. It cannot cancel or force shutdown of any execution; use
400
+ `dispose()` and cooperative `AbortSignal` handling for lifecycle coordination.
324
401
 
325
402
  Missing, circular, and provider-construction failures use the logical Service
326
403
  tags in `ServiceNotFoundError`, `CircularDependencyError`, and
@@ -365,6 +442,103 @@ and its ordered `timeline`; call `clear()` to reuse it. Composition invokes
365
442
  observers in declaration order and isolates thrown or rejected observer
366
443
  failures from the Runtime result.
367
444
 
445
+ ### Optional OpenTelemetry tracing
446
+
447
+ Install the OpenTelemetry API only when this subpath is used:
448
+
449
+ ```bash
450
+ bun add better-effect @opentelemetry/api
451
+ ```
452
+
453
+ `better-effect/opentelemetry` accepts either an existing `Tracer` or an existing
454
+ `TracerProvider`. Its supported `@opentelemetry/api` peer range is
455
+ `>=1.9.0 <1.10.0` (tested with 1.9.1). It never installs a global provider, SDK,
456
+ context manager or exporter. The provider form only calls `getTracer`:
457
+
458
+ ```ts
459
+ import { trace } from '@opentelemetry/api'
460
+ import { OpenTelemetryRuntimeObserver } from 'better-effect/opentelemetry'
461
+
462
+ const observer = OpenTelemetryRuntimeObserver.make({
463
+ // An existing provider can be passed instead with `provider` and `tracerName`.
464
+ tracer: trace.getTracer('acme.application'),
465
+ serviceResolution: 'events',
466
+ recordFailures: true,
467
+ executionAttributeAllowlist: ['requestId'],
468
+ sanitizeFailure: (cause) =>
469
+ cause instanceof KnownDomainError ? { message: cause.code } : undefined
470
+ })
471
+
472
+ const runtime = await Runtime.make(AppLive, { observers: [observer] })
473
+ ```
474
+
475
+ The adapter starts one span for each execution, keyed only by the Runtime's
476
+ `executionId`. The Program name is the span name, with
477
+ `better-effect.execution` as the stable fallback. Executions whose program and
478
+ execution cleanup succeed use OpenTelemetry `OK`; typed `Result.err` values,
479
+ thrown defects and execution cleanup failures use `ERROR`. The Runtime observer
480
+ keeps cleanup failure separate from the primary program outcome, so the adapter
481
+ can report the final execution status without turning cleanup into a program
482
+ defect or serializing its cause by default.
483
+ Span timing is supplied by OpenTelemetry's start/end clock; `durationMs` is not
484
+ written as a second duration source.
485
+
486
+ Service telemetry is explicit and defaults to `off`:
487
+
488
+ - `off` creates no Service telemetry.
489
+ - `events` adds resolution, acquisition and release events to an execution span
490
+ only when the Runtime event carries an explicit active execution ID. Warmup,
491
+ Runtime-root cleanup and other events without an owner use standalone spans.
492
+ - `spans` creates child Service spans only for events with an explicit active
493
+ execution ID. Warmup and Runtime-root cleanup are represented by standalone
494
+ service spans (or generic event-carrier spans in `events` mode); ambient
495
+ OpenTelemetry context is never used to guess ownership.
496
+
497
+ Service identity is always the logical `serviceTag`; constructor names and
498
+ Service instances are never used. `RuntimeObserver.compose` can combine this
499
+ adapter with recorded, graph and application observers, and a tracer failure is
500
+ isolated from every Runtime result. Call `observer.dispose()` when an observer
501
+ may outlive its Runtime to end state left by malformed or missing end events;
502
+ normal execution spans are ended exactly once by their matching `executionId`.
503
+
504
+ Telemetry is privacy-preserving by default. The adapter records only bounded
505
+ library, execution ID, Program/outcome, Service-tag and resolution-path data.
506
+ It does not record causes, stacks, requests, arbitrary execution attributes or
507
+ Service instances, and it never stringifies unknown values. Add attributes only
508
+ through `executionAttributeAllowlist` or the explicit
509
+ `sanitizeExecutionAttributes` callback; add failure details only through
510
+ `sanitizeFailure` together with `recordFailures: true`. Unsupported values are
511
+ dropped, strings are bounded, and default limits are 256 characters, 32
512
+ attributes and 16 resolution-path tags (caller limits remain capped). Sanitizer
513
+ callbacks should return only intentional scalar OpenTelemetry attributes.
514
+
515
+ For distributed tracing, establish the caller's OpenTelemetry context before
516
+ calling `runtime.run`; the adapter does not propagate context or instrument
517
+ HTTP/database libraries automatically.
518
+
519
+ For a startup view of the graph actually observed by a Runtime, compose the
520
+ small graph observer and warm the Layer before accepting work:
521
+
522
+ ```ts
523
+ import { Runtime } from 'better-effect'
524
+ import { RuntimeGraphObserver } from 'better-effect/testing'
525
+
526
+ const graph = RuntimeGraphObserver.make({ rootLabel: 'Runtime' })
527
+ const runtime = await Runtime.make(AppLive, {
528
+ warmup: true,
529
+ observers: [graph]
530
+ })
531
+
532
+ console.log(graph.toJSON())
533
+ console.log(graph.toMermaid())
534
+ await runtime.dispose()
535
+ ```
536
+
537
+ `RuntimeGraphObserver` uses only public resolution and acquisition events. Its
538
+ snapshot is sorted, detached and immutable; it records Service tags and counts,
539
+ not instances, scopes, causes or execution attributes. Providers that are never
540
+ resolved remain absent, and `clear()` starts a new diagnostic session.
541
+
368
542
  Cancellation is cooperative and uses `AbortSignal`; no scheduler or fibers are
369
543
  created. Pass a signal to one execution and read it from the program when an
370
544
  I/O operation supports cancellation. Runtime disposal waits for active work;
@@ -379,6 +553,38 @@ const cancellableProgram = Effect.fn(async function* () {
379
553
  })
380
554
  ```
381
555
 
556
+ 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:
560
+
561
+ ```ts
562
+ import { NodeRuntime } from 'better-effect/node'
563
+
564
+ const main = Effect.fn(async function* () {
565
+ const signal = yield* CurrentAbortSignal
566
+ return Result.ok(await runCommand({ signal }))
567
+ })
568
+
569
+ await NodeRuntime.runMain(AppLive, main, {
570
+ onFailure: (error) => {
571
+ console.error(error)
572
+ return 1
573
+ },
574
+ onSuccess: () => 0
575
+ })
576
+ ```
577
+
578
+ `Result.err` uses `onFailure` (or exit code `1` by default), while thrown defects
579
+ remain rejected and may be reported with `onDefect`. Cleanup-only failures use
580
+ `onCleanupFailure`, remain observable, and still set a non-zero
581
+ `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.
587
+
382
588
  For request-local context or overrides, add a Layer only to that execution:
383
589
 
384
590
  ```ts
@@ -390,12 +596,254 @@ with the execution Scope and never change the shared Runtime environment. Its
390
596
  external requirements must be provided by the Runtime, and the Hono failure
391
597
  handler/request-Layer types are checked at the adapter boundary.
392
598
 
599
+ ### Framework-neutral Web request boundaries
600
+
601
+ The `better-effect/web` entrypoint provides a small Request-to-Response
602
+ boundary without depending on Hono or another framework. Use it anywhere a
603
+ framework gives you standard Web `Request` and `Response` values:
604
+
605
+ ```ts
606
+ import { Result } from 'better-result'
607
+ import { CurrentAbortSignal, Effect } from 'better-effect'
608
+ import { CurrentRequest } from 'better-effect/standard-services'
609
+ import { WebEffect } from 'better-effect/web'
610
+
611
+ const handleRequest = (request: Request) =>
612
+ WebEffect.handle(
613
+ runtime,
614
+ request,
615
+ Effect.fn(async function* () {
616
+ const currentRequest = yield* CurrentRequest
617
+ const signal = yield* CurrentAbortSignal
618
+ const user = yield* UserService
619
+ const result = yield* Result.await(user.find())
620
+
621
+ return Result.ok({
622
+ result,
623
+ url: (currentRequest.request as Request).url,
624
+ aborted: signal.aborted
625
+ })
626
+ })
627
+ )
628
+ ```
629
+
630
+ `WebEffect.handle` supplies `CurrentRequest`, forwards `request.signal`, and
631
+ runs one lazy Program in a child Scope. Request-local resources are released
632
+ before the returned Promise resolves; Runtime-root resources remain owned by
633
+ the Runtime. A `requestLayer` option can add per-request providers or
634
+ intentionally override a compatible request tag.
635
+
636
+ The default success policy passes through a Web `Response`, maps top-level
637
+ `undefined` to 204, and wraps supported values as `{ data: value }` JSON. A
638
+ non-`undefined`, non-`Response` value must be an acyclic graph of `null`,
639
+ booleans, strings, finite numbers, dense arrays, and plain object records.
640
+ Arrays must have only the own string properties `length` and one property for
641
+ each index from `0` through `length - 1`; each index must be an enumerable data
642
+ property. Symbol keys, extra own properties (including non-enumerable ones),
643
+ sparse holes, and accessor elements are rejected. Object records must have
644
+ `Object.prototype` or `null` as their prototype, and every own string-keyed
645
+ property must be an enumerable data property. Own symbol keys,
646
+ non-enumerable properties, accessors, custom prototypes, and other non-plain
647
+ objects are rejected. Shared references in separate branches are serialized as
648
+ repeated values. Nested `undefined`, `bigint`, functions, symbols, and
649
+ non-finite numbers (`NaN` and infinities) are rejected with
650
+ `WebEffectSerializationError` (a `TypeError`) instead of being silently dropped
651
+ or coerced. Use an explicit `onSuccess` policy for other representations. The
652
+ default failure policy passes through a standards-compatible `Response`
653
+ failure and redacts every other typed failure to `{ error: 'Internal Server Error' }`
654
+ with status 500. Custom
655
+ `onSuccess`/`onFailure` policies may be asynchronous but must return a
656
+ standards-compatible `Response`. The boundary checks that protocol structurally:
657
+ `status` is an integer in `0` or `200` through `599`, `ok` matches the 2xx
658
+ status range, `redirected` and `bodyUsed` are booleans, `statusText` and `url`
659
+ are strings, `type` is a standard Response type, and
660
+ `arrayBuffer()`, `blob()`, `bytes()`, `clone()`, `formData()`, `json()`, and
661
+ `text()` are callable. `headers` must provide callable `append()`, `delete()`,
662
+ `get()`, `getSetCookie()`, `has()`, `set()`, and `forEach()` operations. `body`
663
+ must be `null` (including legitimate null-body Responses such as `204`) or a
664
+ ReadableStream-compatible object with a boolean `locked` property and callable
665
+ `cancel()`, `getReader()`, `pipeThrough()`, `pipeTo()`, and `tee()` methods.
666
+ Native cross-realm Responses and other values satisfying this protocol are
667
+ accepted without `instanceof Response`; missing capabilities and forged
668
+ `Response.prototype` values fail with `TypeError`. Thrown defects remain
669
+ rejected. The Program's Service and failure channels, request-Layer
670
+ requirements, and override compatibility are checked at the TypeScript
671
+ boundary.
672
+
673
+ ### Next.js App Router request boundaries
674
+
675
+ The optional `better-effect/next` entrypoint adapts an application-owned
676
+ Runtime to native Next.js App Router Route Handlers without importing Next.js
677
+ from the core or main entrypoint:
678
+
679
+ ```ts
680
+ import { Result } from 'better-result'
681
+ import { Effect, Layer, Runtime, Service } from 'better-effect'
682
+ import { NextEffect } from 'better-effect/next'
683
+
684
+ class UserService extends Service<UserService>()('UserService') {
685
+ find(id: string) {
686
+ return { id, name: 'Ada' }
687
+ }
688
+ }
689
+
690
+ const runtime = await Runtime.make(Layer.make(UserService))
691
+ const http = NextEffect.make(runtime)
692
+
693
+ export const GET = http.gen(async function* (_request, context: RouteContext<'/api/users/[id]'>) {
694
+ const { id } = await context.params
695
+ const users = yield* UserService
696
+
697
+ return Result.ok(users.find(id))
698
+ })
699
+ ```
700
+
701
+ `NextEffect.gen` and `NextEffect.handler` return the native
702
+ `(request, context) => Promise<Response>` shape expected by App Router route
703
+ files. The context is typed with Next's asynchronous `params`; `handler` is
704
+ useful when the complete `Effect.fn` Program already exists.
705
+
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:
708
+
709
+ ```ts
710
+ // app/runtime.ts
711
+ export const appRuntime = await Runtime.make(AppLive)
712
+
713
+ // Call this from the actual host/server shutdown hook.
714
+ export const disposeAppRuntime = (): Promise<void> => appRuntime.dispose()
715
+ ```
716
+
717
+ Route modules import that Runtime and bind it to the adapter:
718
+
719
+ ```ts
720
+ // app/api/users/[id]/route.ts
721
+ import { appRuntime } from '@/app/runtime'
722
+
723
+ const http = NextEffect.make(appRuntime)
724
+ ```
725
+
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:
733
+
734
+ ```ts
735
+ export const GET = (request, context) =>
736
+ Runtime.use(AppLive, (runtime) =>
737
+ NextEffect.make(runtime).handler(() => getUser(context))(request, context)
738
+ )
739
+ ```
740
+
741
+ Each request creates one WebEffect execution and child Scope, installs
742
+ `CurrentRequest` and `CurrentAbortSignal`, and releases request-local Layers
743
+ before the handler Promise resolves. The request's
744
+ `AbortSignal` is linked into the execution signal, and concurrent requests keep
745
+ request Layers and context isolated.
746
+
747
+ The default success policy passes through a returned `Response`; other values
748
+ are encoded as `{ data: value }` JSON using the same strict serialization rules
749
+ as `better-effect/web`. A route may select at most one success policy:
750
+ `respond` handles a complete Response, `serialize` transforms a JSON-safe
751
+ value, and route-level `onSuccess` replaces the shared success policy. Shared
752
+ `onSuccess` and `onFailure` policies receive the native Request and route
753
+ context. Typed
754
+ `Result.err` failures use `onFailure` (or the redacted 500 default), while
755
+ thrown defects remain rejected. Policies must return a standards-compatible
756
+ `Response` and may be asynchronous. This adapter does not add Edge-runtime
757
+ support; use it only in hosts supported by the configured better-effect
758
+ Runtime.
759
+
760
+ Install Next.js only when this subpath is used:
761
+
762
+ ```bash
763
+ bun add better-effect better-result next
764
+ ```
765
+
766
+ `next` is an optional peer dependency, and `better-effect/next` uses only the
767
+ public native Route Handler shape, so the core and other entrypoints remain
768
+ usable without Next.js.
769
+
770
+ ### Bun.serve fetch adapter
771
+
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:
776
+
777
+ ```ts
778
+ import { Result } from 'better-result'
779
+ import { CurrentAbortSignal, Effect, Layer, Runtime, Service } from 'better-effect'
780
+ import { CurrentRequest } from 'better-effect/standard-services'
781
+ import { BunEffect } from 'better-effect/bun'
782
+
783
+ class AppService extends Service<AppService>()('AppService') {
784
+ handle(url: string) {
785
+ return url
786
+ }
787
+ }
788
+
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
809
+ })
810
+ })
811
+ )
812
+ })
813
+ ```
814
+
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`.
820
+
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:
824
+
825
+ ```ts
826
+ await server.stop()
827
+ await runtime.dispose()
828
+ ```
829
+
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
+ This Bun-specific entrypoint makes no Node compatibility claim.
834
+
393
835
  ### Hono request boundaries
394
836
 
395
- The optional `better-effect/hono` entrypoint runs one Runtime execution and
396
- Scope around each request. Handlers can yield Services directly; the adapter
397
- provides `CurrentRequest`, forwards `Request.signal`, and converts Results to
398
- Responses in one policy:
837
+ The optional `better-effect/hono` entrypoint supplies Hono's middleware,
838
+ validator, and Context integration around the same Web request boundary used
839
+ by `better-effect/web`. It adapts Hono's Context to a standard Web `Request`,
840
+ then delegates the single `Runtime` execution, Result/defect handling, and
841
+ request Scope cleanup to that shared boundary. You only need to configure one
842
+ Hono middleware boundary per request; the adapter prevents duplicate
843
+ registrations from opening another execution.
844
+
845
+ Handlers can yield Services directly; the adapter provides `CurrentRequest`,
846
+ forwards `Request.signal`, and converts Results to Responses in one policy:
399
847
 
400
848
  ```ts
401
849
  import { Hono } from 'hono'
@@ -671,10 +1119,96 @@ const now = await runtime.run(async () => ServiceRuntime.resolve(Clock))
671
1119
  await runtime.dispose()
672
1120
  ```
673
1121
 
674
- The entrypoint also provides `Random`/`RandomSeeded`, `Logger`/`LoggerTest`,
675
- `Config`, `CurrentRequest`, and the compatible `CurrentAbortSignal` bridge. None
676
- is installed implicitly; compose a normal Layer or use the provided test
677
- helpers.
1122
+ `IdGenerator` uses the host's cryptographic `crypto.randomUUID()` and is also
1123
+ opt-in. Use `IdGeneratorTest` to make entity creation deterministic without
1124
+ introducing a domain-specific ID type:
1125
+
1126
+ ```ts
1127
+ import { Result } from 'better-result'
1128
+ import { Effect, Layer, Service } from 'better-effect'
1129
+ import { IdGenerator } from 'better-effect/standard-services'
1130
+ import { IdGeneratorTest, TestRuntime } from 'better-effect/testing'
1131
+
1132
+ class User extends Service<User>()('User') {
1133
+ constructor(readonly id: string) {
1134
+ super()
1135
+ }
1136
+ }
1137
+
1138
+ const createUser = Effect.fn(async function* () {
1139
+ const ids = yield* IdGenerator
1140
+ return Result.ok(new User(ids.next()))
1141
+ })
1142
+
1143
+ const result = await TestRuntime.use(
1144
+ Layer.merge(),
1145
+ { idGenerator: new IdGeneratorTest(['user-1']) },
1146
+ (test) => test.run(createUser)
1147
+ )
1148
+ // Result.ok(User { id: 'user-1' })
1149
+ ```
1150
+
1151
+ `IdGeneratorTest.from((index) => ...)` provides an unbounded deterministic
1152
+ sequence; its first factory index is zero and increases monotonically. The
1153
+ entrypoint also provides `Random`/`RandomSeeded`, `Logger`/`LoggerTest`, `Config`,
1154
+ `CurrentRequest`, and the compatible `CurrentAbortSignal` bridge. None is
1155
+ installed implicitly; compose a normal Layer or use the provided test helpers.
1156
+
1157
+ `Clock.sleep` keeps the original `clock.sleep(milliseconds)` form and accepts
1158
+ an optional `AbortSignal`. Invalid delays still throw synchronously. Aborted
1159
+ sleeps clear their timer and listener; a supplied `signal.reason` is rejected
1160
+ unchanged, otherwise the rejection is an `AbortError`-named `DOMException`.
1161
+
1162
+ Use the same signal in polling, retry delays and expiration checks:
1163
+
1164
+ ```ts
1165
+ const poll = async (clock: Clock, signal: AbortSignal) => {
1166
+ while (true) {
1167
+ const status = await readStatus()
1168
+ if (status.ready) return status
1169
+ await clock.sleep(1_000, { signal })
1170
+ }
1171
+ }
1172
+
1173
+ const retry = async <A>(operation: () => Promise<A>, clock: Clock, signal: AbortSignal) => {
1174
+ for (let attempt = 0; ; attempt += 1) {
1175
+ try {
1176
+ return await operation()
1177
+ } catch (error) {
1178
+ if (attempt === 2) throw error
1179
+ await clock.sleep(100 * 2 ** attempt, { signal })
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ const waitUntilExpired = async (clock: Clock, expiresAt: number, signal: AbortSignal) => {
1185
+ while (clock.now().getTime() < expiresAt) {
1186
+ await clock.sleep(Math.min(expiresAt - clock.now().getTime(), 1_000), { signal })
1187
+ }
1188
+ }
1189
+ ```
1190
+
1191
+ `ClockTest` orders sleeps by absolute deadline and FIFO for equal deadlines.
1192
+ `pendingSleeps` is a readonly count. `advanceToNext()` returns `false` when
1193
+ there is no pending sleep; `runAll({ maxSteps })` advances repeatedly and
1194
+ awaits one microtask checkpoint between deadlines so resumed code can schedule
1195
+ its next wait:
1196
+
1197
+ ```ts
1198
+ const clock = new ClockTest(0)
1199
+ const task = (async () => {
1200
+ await clock.sleep(100)
1201
+ await clock.sleep(50)
1202
+ })()
1203
+
1204
+ await clock.runAll({ maxSteps: 10 })
1205
+ await task
1206
+ // clock.now().getTime() === 150
1207
+ ```
1208
+
1209
+ `setTime` may move backward. Pending sleeps keep their absolute deadlines and
1210
+ resolve only when reached. `ClockTest` does not virtualize `Date`, global
1211
+ `setTimeout` or the JavaScript microtask queue.
678
1212
 
679
1213
  For typed environment configuration, bind a Standard Schema directly to a
680
1214
  reusable descriptor:
@@ -1,5 +1,6 @@
1
- import { N as AnyServiceToken } from "../index-heuaRmXR.mjs";
2
- import { v as LayerBackend, x as LayerRegistration, y as LayerBackendDisposeOptions } from "../index-rQhZk3Nt.mjs";
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";
3
4
  //#region src/adapters/iti.d.ts
4
5
  /**
5
6
  * ITI-backed Layer backend.
@@ -1 +1 @@
1
- {"version":3,"file":"iti.d.mts","names":[],"sources":["../../src/adapters/iti.ts"],"mappings":";;;;;;;;;cAuBa,2BAA2B;UAC9B;mBAES;mBAEA;;;;;;mBAOA;UAET;;EAgBR,SAAS,cAAc;;EAuBvB,QAAQ,UAAU,iBAAiB,OAAO,IAAI,aAAa,KAAK,YAAY,aAAa;;EAiCnF,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 { a as ServiceTagCollisionError, c as ServiceNotFoundError, t as DuplicateServiceError } from "../errors-Dnjhzbt0.mjs";
1
+ import { f as captureServiceTag, n as normalizeLayerRegistration, r as DuplicateServiceError, s as ServiceTagCollisionError, t as captureLayerRegistrationTag, u as ServiceNotFoundError } from "../registration-drnZk0SY.mjs";
2
2
  import { t as isPromiseLike } from "../runtime-CDcCF5cb.mjs";
3
- import { t as assertServiceCompatibility } from "../internal-identity-DmUpBeeL.mjs";
3
+ import { t as assertServiceCompatibility } from "../internal-identity-CbXoZIfw.mjs";
4
4
  import { createContainer } from "iti";
5
5
  //#region src/adapters/iti.ts
6
6
  /**
@@ -19,8 +19,7 @@ var ItiLayerBackend = class {
19
19
  * retry boundary for that sticky failure behavior.
20
20
  */
21
21
  pending = /* @__PURE__ */ new Map();
22
- keyFor(token) {
23
- const tag = token.serviceTag;
22
+ keyFor(tag) {
24
23
  const existing = this.keys.get(tag);
25
24
  if (existing) return existing;
26
25
  const key = `better-effect:${tag}`;
@@ -29,20 +28,22 @@ var ItiLayerBackend = class {
29
28
  }
30
29
  /** Register a Layer provider under its deterministic Service-tag key. */
31
30
  register(registration) {
32
- const token = registration.service;
33
- const tag = token.serviceTag;
31
+ const normalized = normalizeLayerRegistration(registration);
32
+ const token = normalized.service;
33
+ const tag = captureLayerRegistrationTag(normalized);
34
34
  const existing = this.registered.get(tag);
35
35
  if (existing === token) throw new DuplicateServiceError(token);
36
36
  if (existing) throw new ServiceTagCollisionError(existing, token);
37
- const key = this.keyFor(token);
38
- this.container = this.container.add({ [key]: registration.acquire });
37
+ const key = this.keyFor(tag);
38
+ this.container = this.container.add({ [key]: normalized.acquire });
39
39
  this.registered.set(tag, token);
40
40
  }
41
41
  /** Resolve a registered Service through the ITI container. */
42
42
  resolve(token) {
43
- const registered = this.registered.get(token.serviceTag);
43
+ const tag = captureServiceTag(token);
44
+ const registered = this.registered.get(tag);
44
45
  if (registered === void 0) throw new ServiceNotFoundError(token);
45
- const key = this.keyFor(token);
46
+ const key = this.keyFor(tag);
46
47
  const resolved = this.container.get(key);
47
48
  const validate = (instance) => {
48
49
  assertServiceCompatibility(token, registered, instance);