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.
- package/README.md +544 -10
- package/dist/adapters/iti.d.mts +3 -2
- package/dist/adapters/iti.d.mts.map +1 -1
- package/dist/adapters/iti.mjs +11 -10
- package/dist/adapters/iti.mjs.map +1 -1
- package/dist/bun.d.mts +43 -0
- package/dist/bun.d.mts.map +1 -0
- package/dist/bun.mjs +40 -0
- package/dist/bun.mjs.map +1 -0
- package/dist/{context-BUEf1qjL.mjs → context-BphfFUMO.mjs} +4 -2
- package/dist/context-BphfFUMO.mjs.map +1 -0
- package/dist/current-request-CIU7AHhF.d.mts +22 -0
- package/dist/current-request-CIU7AHhF.d.mts.map +1 -0
- package/dist/current-request-DDKNyymv.mjs +20 -0
- package/dist/current-request-DDKNyymv.mjs.map +1 -0
- package/dist/disposable-CP-Q2H3l.mjs +57 -0
- package/dist/disposable-CP-Q2H3l.mjs.map +1 -0
- package/dist/{effect-2ZcGZI8A.mjs → effect-CPdV7Yv8.mjs} +56 -10
- package/dist/effect-CPdV7Yv8.mjs.map +1 -0
- package/dist/hono.d.mts +7 -5
- package/dist/hono.d.mts.map +1 -1
- package/dist/hono.mjs +88 -30
- package/dist/hono.mjs.map +1 -1
- package/dist/{index-heuaRmXR.d.mts → index-AiKM71-u.d.mts} +3 -1
- package/dist/{index-heuaRmXR.d.mts.map → index-AiKM71-u.d.mts.map} +1 -1
- package/dist/index-BodMy6rO.d.mts +180 -0
- package/dist/index-BodMy6rO.d.mts.map +1 -0
- package/dist/{index-C7Qild0_.d.mts → index-CCi07kUw.d.mts} +15 -151
- package/dist/index-CCi07kUw.d.mts.map +1 -0
- package/dist/index-DAKn0qHk.d.mts +19 -0
- package/dist/index-DAKn0qHk.d.mts.map +1 -0
- package/dist/{index-DStz0JMN.d.mts → index-cXgyMa0v.d.mts} +75 -22
- package/dist/index-cXgyMa0v.d.mts.map +1 -0
- package/dist/index.d.mts +7 -4
- package/dist/index.mjs +8 -6
- package/dist/index.mjs.map +1 -1
- package/dist/{internal-identity-DmUpBeeL.mjs → internal-identity-CbXoZIfw.mjs} +3 -3
- package/dist/internal-identity-CbXoZIfw.mjs.map +1 -0
- package/dist/{signal-B97cs85Z.mjs → layer-D16GKxuX.mjs} +31 -112
- package/dist/layer-D16GKxuX.mjs.map +1 -0
- package/dist/next.d.mts +116 -0
- package/dist/next.d.mts.map +1 -0
- package/dist/next.mjs +68 -0
- package/dist/next.mjs.map +1 -0
- package/dist/node-context-Ca18oNSg.mjs +25 -0
- package/dist/node-context-Ca18oNSg.mjs.map +1 -0
- package/dist/node-context-DczjAcrX.d.mts +12 -0
- package/dist/node-context-DczjAcrX.d.mts.map +1 -0
- package/dist/node.d.mts +51 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +233 -0
- package/dist/node.mjs.map +1 -0
- package/dist/observer-9nhbL7Pr.d.mts +68 -0
- package/dist/observer-9nhbL7Pr.d.mts.map +1 -0
- package/dist/opentelemetry.d.mts +94 -0
- package/dist/opentelemetry.d.mts.map +1 -0
- package/dist/opentelemetry.mjs +453 -0
- package/dist/opentelemetry.mjs.map +1 -0
- package/dist/{index-rQhZk3Nt.d.mts → outcome-DI8LPIQ4.d.mts} +27 -72
- package/dist/outcome-DI8LPIQ4.d.mts.map +1 -0
- package/dist/{scope-GGnmTQck.mjs → program-metadata-BxjGkT17.mjs} +19 -4
- package/dist/program-metadata-BxjGkT17.mjs.map +1 -0
- package/dist/registration-drnZk0SY.mjs +182 -0
- package/dist/registration-drnZk0SY.mjs.map +1 -0
- package/dist/runtime/explicit.d.mts +1 -1
- package/dist/runtime/explicit.mjs +1 -1
- package/dist/runtime/node.d.mts +3 -12
- package/dist/runtime/node.mjs +2 -24
- package/dist/{runtime-DnMn0X0X.mjs → runtime-C_55ofnL.mjs} +245 -67
- package/dist/runtime-C_55ofnL.mjs.map +1 -0
- package/dist/signal-nfgFyNCk.mjs +52 -0
- package/dist/signal-nfgFyNCk.mjs.map +1 -0
- package/dist/{standard-services-BFBq-4lo.mjs → standard-services-vnGIafkS.mjs} +200 -30
- package/dist/standard-services-vnGIafkS.mjs.map +1 -0
- package/dist/standard-services.d.mts +4 -3
- package/dist/standard-services.mjs +4 -3
- package/dist/testing.d.mts +67 -7
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +192 -9
- package/dist/testing.mjs.map +1 -1
- package/dist/types-oihI-ESv.d.mts +54 -0
- package/dist/types-oihI-ESv.d.mts.map +1 -0
- package/dist/web-Bq3MfMrj.mjs +147 -0
- package/dist/web-Bq3MfMrj.mjs.map +1 -0
- package/dist/web.d.mts +47 -0
- package/dist/web.d.mts.map +1 -0
- package/dist/web.mjs +2 -0
- package/package.json +39 -3
- package/dist/context-BUEf1qjL.mjs.map +0 -1
- package/dist/effect-2ZcGZI8A.mjs.map +0 -1
- package/dist/errors-Dnjhzbt0.mjs +0 -99
- package/dist/errors-Dnjhzbt0.mjs.map +0 -1
- package/dist/index-C7Qild0_.d.mts.map +0 -1
- package/dist/index-DStz0JMN.d.mts.map +0 -1
- package/dist/index-rQhZk3Nt.d.mts.map +0 -1
- package/dist/internal-identity-DmUpBeeL.mjs.map +0 -1
- package/dist/runtime/node.d.mts.map +0 -1
- package/dist/runtime/node.mjs.map +0 -1
- package/dist/runtime-DnMn0X0X.mjs.map +0 -1
- package/dist/scope-GGnmTQck.mjs.map +0 -1
- package/dist/signal-B97cs85Z.mjs.map +0 -1
- 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
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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:
|
package/dist/adapters/iti.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { N as AnyServiceToken } from "../index-
|
|
2
|
-
import {
|
|
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":"
|
|
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"}
|
package/dist/adapters/iti.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
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-
|
|
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(
|
|
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
|
|
33
|
-
const
|
|
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(
|
|
38
|
-
this.container = this.container.add({ [key]:
|
|
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
|
|
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(
|
|
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);
|