evnty 5.1.1 → 5.2.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/package.json +2 -2
- package/src/async.ts +112 -0
- package/src/broadcast.ts +348 -0
- package/src/dispatch-result.ts +166 -0
- package/src/event.ts +408 -0
- package/src/iterator.ts +899 -0
- package/src/listener-registry.ts +178 -0
- package/src/ring-buffer.ts +234 -0
- package/src/sequence.ts +184 -0
- package/src/signal.ts +135 -0
- package/src/types.ts +137 -0
- package/src/utils.ts +426 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evnty",
|
|
3
3
|
"description": "Async-first, reactive event handling library for complex event flows in browser and Node.js",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
7
7
|
"main": "build/index.cjs",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"build",
|
|
15
|
-
"src
|
|
15
|
+
"src/*.ts",
|
|
16
16
|
"src/__tests__/example.js"
|
|
17
17
|
],
|
|
18
18
|
"sideEffects": false,
|
package/src/async.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Action, Fn, Emitter, MaybePromise, Promiseable } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export class Disposer {
|
|
7
|
+
#target?: Disposable;
|
|
8
|
+
#abortSignal?: AbortSignal;
|
|
9
|
+
|
|
10
|
+
constructor(target: Disposable, abortSignal?: AbortSignal) {
|
|
11
|
+
if (abortSignal?.aborted) return;
|
|
12
|
+
this.#target = target;
|
|
13
|
+
if (abortSignal) {
|
|
14
|
+
this.#abortSignal = abortSignal;
|
|
15
|
+
abortSignal.addEventListener('abort', this);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get disposed(): boolean {
|
|
20
|
+
return !this.#target;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
[Symbol.dispose](): boolean {
|
|
24
|
+
if (!this.#target) return false;
|
|
25
|
+
this.#target = undefined;
|
|
26
|
+
// Stryker disable all: cleanup is memory optimization, no observable behavior after disposal
|
|
27
|
+
if (this.#abortSignal) {
|
|
28
|
+
this.#abortSignal.removeEventListener('abort', this);
|
|
29
|
+
this.#abortSignal = undefined;
|
|
30
|
+
}
|
|
31
|
+
// Stryker restore all
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
handleEvent(): void {
|
|
36
|
+
// Stryker disable next-line OptionalChaining: #target is always set when abort listener fires (synchronous code)
|
|
37
|
+
this.#target?.[Symbol.dispose]();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
export interface Async<T, R> extends Emitter<T, R>, Disposable {}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
export abstract class Async<T, R> implements Emitter<T, R>, Promiseable<T>, Promise<T>, AsyncIterator<T, void, void>, AsyncIterable<T> {
|
|
50
|
+
abstract [Symbol.toStringTag]: string;
|
|
51
|
+
abstract emit(value: T): R;
|
|
52
|
+
abstract receive(): Promise<T>;
|
|
53
|
+
|
|
54
|
+
dispose?(): void;
|
|
55
|
+
|
|
56
|
+
#sink?: Fn<[T], R>;
|
|
57
|
+
#disposer: Disposer;
|
|
58
|
+
|
|
59
|
+
constructor(abortSignal?: AbortSignal) {
|
|
60
|
+
this.#disposer = new Disposer(this, abortSignal);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get disposed(): boolean {
|
|
64
|
+
return this.#disposer.disposed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get sink(): Fn<[T], R> {
|
|
68
|
+
return (this.#sink ??= this.emit.bind(this));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
handleEvent(event: T) {
|
|
72
|
+
this.emit(event);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
catch<OK = never>(onrejected?: Fn<[unknown], MaybePromise<OK>> | null): Promise<T | OK> {
|
|
76
|
+
return this.receive().catch(onrejected);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
finally(onfinally?: Action | null): Promise<T> {
|
|
80
|
+
return this.receive().finally(onfinally);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
then<OK = T, ERR = never>(onfulfilled?: Fn<[T], MaybePromise<OK>> | null, onrejected?: Fn<[unknown], MaybePromise<ERR>> | null): Promise<OK | ERR> {
|
|
84
|
+
return this.receive().then(onfulfilled, onrejected);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async next(): Promise<IteratorResult<T, void>> {
|
|
88
|
+
try {
|
|
89
|
+
const value = await this.receive();
|
|
90
|
+
return { value, done: false };
|
|
91
|
+
} catch {
|
|
92
|
+
return { value: undefined, done: true };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async return(): Promise<IteratorResult<T, void>> {
|
|
97
|
+
// Stryker disable next-line OptionalChaining: all subclasses define dispose()
|
|
98
|
+
this.dispose?.();
|
|
99
|
+
return { value: undefined, done: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
[Symbol.asyncIterator](): AsyncIterator<T, void, void> {
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
[Symbol.dispose](): void {
|
|
107
|
+
if (this.#disposer[Symbol.dispose]()) {
|
|
108
|
+
// Stryker disable next-line OptionalChaining: all subclasses define dispose()
|
|
109
|
+
this.dispose?.();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/broadcast.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { RingBuffer } from './ring-buffer.js';
|
|
2
|
+
import { Signal } from './signal.js';
|
|
3
|
+
import { Disposer } from './async.js';
|
|
4
|
+
import { min } from './utils.js';
|
|
5
|
+
import { Action, Fn, Emitter, MaybePromise, Promiseable } from './types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A handle representing a consumer's position in a Broadcast.
|
|
9
|
+
* Returned by `Broadcast.join()` and used to consume values.
|
|
10
|
+
* Implements Disposable for automatic cleanup via `using` keyword.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const broadcast = new Broadcast<number>();
|
|
15
|
+
* using handle = broadcast.join();
|
|
16
|
+
* broadcast.emit(42);
|
|
17
|
+
* const value = broadcast.consume(handle); // 42
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export class ConsumerHandle<T> implements Disposable {
|
|
21
|
+
#broadcast: Broadcast<T>;
|
|
22
|
+
|
|
23
|
+
constructor(broadcast: Broadcast<T>) {
|
|
24
|
+
this.#broadcast = broadcast;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The current position of this consumer in the buffer.
|
|
29
|
+
*/
|
|
30
|
+
get cursor(): number {
|
|
31
|
+
return this.#broadcast.getCursor(this);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Leaves the broadcast, releasing this consumer's position.
|
|
36
|
+
*/
|
|
37
|
+
[Symbol.dispose](): void {
|
|
38
|
+
this.#broadcast.leave(this);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
export class BroadcastIterator<T> implements AsyncIterator<T, void, void> {
|
|
46
|
+
#broadcast: Broadcast<T>;
|
|
47
|
+
#signal: Signal<T>;
|
|
48
|
+
#handle: ConsumerHandle<T>;
|
|
49
|
+
|
|
50
|
+
constructor(broadcast: Broadcast<T>, signal: Signal<T>, handle: ConsumerHandle<T>) {
|
|
51
|
+
this.#broadcast = broadcast;
|
|
52
|
+
this.#signal = signal;
|
|
53
|
+
this.#handle = handle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async next(): Promise<IteratorResult<T, void>> {
|
|
57
|
+
try {
|
|
58
|
+
while (true) {
|
|
59
|
+
const result = this.#broadcast.tryConsume(this.#handle);
|
|
60
|
+
if (!result.done) {
|
|
61
|
+
return { value: result.value, done: false };
|
|
62
|
+
}
|
|
63
|
+
await this.#signal.receive();
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
return { value: undefined, done: true };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async return(): Promise<IteratorResult<T, void>> {
|
|
71
|
+
this.#broadcast.leave(this.#handle);
|
|
72
|
+
return { value: undefined, done: true };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A multi-consumer FIFO queue where each consumer maintains its own read position.
|
|
78
|
+
* Values are buffered and each consumer can read them independently at their own pace.
|
|
79
|
+
* The buffer automatically compacts when all consumers have read past a position.
|
|
80
|
+
*
|
|
81
|
+
* Key characteristics:
|
|
82
|
+
* - Multiple consumers - each gets their own cursor position
|
|
83
|
+
* - Buffered delivery - values are stored until all consumers read them
|
|
84
|
+
* - Late joiners only see values emitted after joining
|
|
85
|
+
* - Automatic cleanup via FinalizationRegistry when handles are garbage collected
|
|
86
|
+
*
|
|
87
|
+
* Differs from:
|
|
88
|
+
* - Event: Broadcast buffers values, Event does not
|
|
89
|
+
* - Sequence: Broadcast supports multiple consumers, Sequence is single-consumer
|
|
90
|
+
* - Signal: Broadcast buffers values, Signal only notifies current waiters
|
|
91
|
+
*
|
|
92
|
+
* @template T - The type of values in the broadcast
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* const broadcast = new Broadcast<number>();
|
|
97
|
+
*
|
|
98
|
+
* const handle1 = broadcast.join();
|
|
99
|
+
* const handle2 = broadcast.join();
|
|
100
|
+
*
|
|
101
|
+
* broadcast.emit(1);
|
|
102
|
+
* broadcast.emit(2);
|
|
103
|
+
*
|
|
104
|
+
* broadcast.consume(handle1); // 1
|
|
105
|
+
* broadcast.consume(handle2); // 1
|
|
106
|
+
* broadcast.consume(handle1); // 2
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export class Broadcast<T> implements Emitter<T, boolean>, Promiseable<T>, Promise<T>, Disposable, AsyncIterable<T> {
|
|
110
|
+
#buffer = new RingBuffer<T>();
|
|
111
|
+
#signal = new Signal<T>();
|
|
112
|
+
#disposer: Disposer;
|
|
113
|
+
#sink?: Fn<[T], boolean>;
|
|
114
|
+
#nextId = 0;
|
|
115
|
+
#cursors = new Map<number, number>();
|
|
116
|
+
#handles = new WeakMap<ConsumerHandle<T>, number>();
|
|
117
|
+
#minCursor = 0;
|
|
118
|
+
|
|
119
|
+
// Stryker disable all: FinalizationRegistry callback only testable via GC, excluded from mutation testing
|
|
120
|
+
#registry = new FinalizationRegistry<number>((id) => {
|
|
121
|
+
const cursor = this.#cursors.get(id)!;
|
|
122
|
+
this.#cursors.delete(id);
|
|
123
|
+
|
|
124
|
+
if (cursor === this.#minCursor) {
|
|
125
|
+
this.#minCursor = min(this.#cursors.values(), this.#buffer.right);
|
|
126
|
+
const shift = this.#minCursor - this.#buffer.left;
|
|
127
|
+
if (shift > 0) this.#buffer.shiftN(shift);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
// Stryker restore all
|
|
131
|
+
|
|
132
|
+
readonly [Symbol.toStringTag] = 'Broadcast';
|
|
133
|
+
|
|
134
|
+
constructor() {
|
|
135
|
+
this.#disposer = new Disposer(this);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Returns a bound emit function for use as a callback.
|
|
140
|
+
*/
|
|
141
|
+
get sink(): Fn<[T], boolean> {
|
|
142
|
+
return (this.#sink ??= this.emit.bind(this));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* DOM EventListener interface compatibility.
|
|
147
|
+
*/
|
|
148
|
+
handleEvent(event: T): void {
|
|
149
|
+
this.emit(event);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The number of active consumers.
|
|
154
|
+
*/
|
|
155
|
+
get size(): number {
|
|
156
|
+
return this.#cursors.size;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Emits a value to all consumers. The value is buffered for consumption.
|
|
161
|
+
*
|
|
162
|
+
* @param value - The value to emit.
|
|
163
|
+
* @returns `true` if the value was emitted.
|
|
164
|
+
*/
|
|
165
|
+
emit(value: T): boolean {
|
|
166
|
+
if (this.#disposer.disposed) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
this.#buffer.push(value);
|
|
170
|
+
this.#signal.emit(value);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Waits for the next emitted value without joining as a consumer.
|
|
176
|
+
* Does not buffer - only receives values emitted after calling.
|
|
177
|
+
*
|
|
178
|
+
* @returns A promise that resolves with the next emitted value.
|
|
179
|
+
*/
|
|
180
|
+
receive(): Promise<T> {
|
|
181
|
+
return this.#signal.receive();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
then<OK = T, ERR = never>(onfulfilled?: Fn<[T], MaybePromise<OK>> | null, onrejected?: Fn<[unknown], MaybePromise<ERR>> | null): Promise<OK | ERR> {
|
|
185
|
+
return this.receive().then(onfulfilled, onrejected);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
catch<ERR = never>(onrejected?: Fn<[unknown], MaybePromise<ERR>> | null): Promise<T | ERR> {
|
|
189
|
+
return this.receive().catch(onrejected);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
finally(onfinally?: Action | null): Promise<T> {
|
|
193
|
+
return this.receive().finally(onfinally);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Joins the broadcast as a consumer. Returns a handle used to consume values.
|
|
198
|
+
* The consumer starts at the current buffer position and will only see
|
|
199
|
+
* values emitted after joining.
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```typescript
|
|
203
|
+
* const handle = broadcast.join();
|
|
204
|
+
* // Use handle with consume(), readable(), leave()
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
join(): ConsumerHandle<T> {
|
|
208
|
+
// Stryker disable next-line UpdateOperator: IDs only need uniqueness, direction is irrelevant
|
|
209
|
+
const id = this.#nextId++;
|
|
210
|
+
const cursor = this.#buffer.right;
|
|
211
|
+
const handle = new ConsumerHandle<T>(this);
|
|
212
|
+
|
|
213
|
+
this.#handles.set(handle, id);
|
|
214
|
+
this.#cursors.set(id, cursor);
|
|
215
|
+
|
|
216
|
+
// Stryker disable all: minCursor is an optimization hint; tryConsume recalculates on use
|
|
217
|
+
if (this.#cursors.size === 1 || cursor < this.#minCursor) {
|
|
218
|
+
this.#minCursor = cursor;
|
|
219
|
+
}
|
|
220
|
+
// Stryker restore all
|
|
221
|
+
|
|
222
|
+
this.#registry.register(handle, id, handle);
|
|
223
|
+
return handle;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Gets the current cursor position for a consumer handle.
|
|
228
|
+
*
|
|
229
|
+
* @param handle - The consumer handle.
|
|
230
|
+
* @returns The cursor position.
|
|
231
|
+
* @throws If the handle is invalid (already left or never joined).
|
|
232
|
+
*/
|
|
233
|
+
getCursor(handle: ConsumerHandle<T>): number {
|
|
234
|
+
const id = this.#handles.get(handle);
|
|
235
|
+
// Stryker disable next-line ConditionalExpression: second cursor check catches invalid handles
|
|
236
|
+
if (id === undefined) throw new Error('Invalid handle');
|
|
237
|
+
const cursor = this.#cursors.get(id);
|
|
238
|
+
if (cursor === undefined) throw new Error('Invalid handle');
|
|
239
|
+
return cursor;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Removes a consumer from the broadcast. The handle becomes invalid after this call.
|
|
244
|
+
* Idempotent - calling multiple times has no effect.
|
|
245
|
+
*
|
|
246
|
+
* @param handle - The consumer handle to remove.
|
|
247
|
+
*/
|
|
248
|
+
leave(handle: ConsumerHandle<T>): void {
|
|
249
|
+
const id = this.#handles.get(handle);
|
|
250
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator: subsequent ops are safe with undefined id
|
|
251
|
+
if (id === undefined) return;
|
|
252
|
+
|
|
253
|
+
const cursor = this.#cursors.get(id)!;
|
|
254
|
+
this.#handles.delete(handle);
|
|
255
|
+
this.#cursors.delete(id);
|
|
256
|
+
this.#registry.unregister(handle);
|
|
257
|
+
|
|
258
|
+
// Stryker disable all: compaction condition is optimization; buffer reads are correct regardless
|
|
259
|
+
if (cursor === this.#minCursor) {
|
|
260
|
+
this.#minCursor = min(this.#cursors.values(), this.#buffer.right);
|
|
261
|
+
const shift = this.#minCursor - this.#buffer.left;
|
|
262
|
+
if (shift > 0) this.#buffer.shiftN(shift);
|
|
263
|
+
}
|
|
264
|
+
// Stryker restore all
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Consumes and returns the next value for a consumer.
|
|
269
|
+
* Advances the consumer's cursor position.
|
|
270
|
+
*
|
|
271
|
+
* @param handle - The consumer handle.
|
|
272
|
+
* @throws If no value is available or the handle is invalid.
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* ```typescript
|
|
276
|
+
* if (broadcast.readable(handle)) {
|
|
277
|
+
* const value = broadcast.consume(handle);
|
|
278
|
+
* }
|
|
279
|
+
* ```
|
|
280
|
+
*/
|
|
281
|
+
consume(handle: ConsumerHandle<T>): T {
|
|
282
|
+
const result = this.tryConsume(handle);
|
|
283
|
+
if (result.done) {
|
|
284
|
+
throw new Error('No value available');
|
|
285
|
+
}
|
|
286
|
+
return result.value;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Attempts to consume the next value for a consumer.
|
|
291
|
+
* Returns `{ done: true }` when no value is currently available.
|
|
292
|
+
*
|
|
293
|
+
* @param handle - The consumer handle.
|
|
294
|
+
* @returns The next value, or `{ done: true }` when nothing is available.
|
|
295
|
+
* @throws If the handle is invalid.
|
|
296
|
+
*/
|
|
297
|
+
tryConsume(handle: ConsumerHandle<T>): IteratorResult<T, void> {
|
|
298
|
+
const id = this.#handles.get(handle);
|
|
299
|
+
// Stryker disable next-line ConditionalExpression: second cursor check catches invalid handles
|
|
300
|
+
if (id === undefined) throw new Error('Invalid handle');
|
|
301
|
+
|
|
302
|
+
const cursor = this.#cursors.get(id);
|
|
303
|
+
if (cursor === undefined) throw new Error('Invalid handle');
|
|
304
|
+
if (cursor >= this.#buffer.right) {
|
|
305
|
+
return { value: undefined, done: true };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const value = this.#buffer.peekAt(cursor)!;
|
|
309
|
+
this.#cursors.set(id, cursor + 1);
|
|
310
|
+
|
|
311
|
+
// Stryker disable all: compaction condition is optimization; buffer reads are correct regardless
|
|
312
|
+
if (cursor === this.#minCursor) {
|
|
313
|
+
this.#minCursor = min(this.#cursors.values(), this.#buffer.right);
|
|
314
|
+
const shift = this.#minCursor - this.#buffer.left;
|
|
315
|
+
if (shift > 0) this.#buffer.shiftN(shift);
|
|
316
|
+
}
|
|
317
|
+
// Stryker restore all
|
|
318
|
+
|
|
319
|
+
return { value, done: false };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Checks if there are values available for a consumer to read.
|
|
324
|
+
*
|
|
325
|
+
* @param handle - The consumer handle.
|
|
326
|
+
* @returns `true` if there are unread values, `false` otherwise.
|
|
327
|
+
*/
|
|
328
|
+
readable(handle: ConsumerHandle<T>): boolean {
|
|
329
|
+
return this.getCursor(handle) < this.#buffer.right;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
[Symbol.asyncIterator](): AsyncIterator<T, void, void> {
|
|
333
|
+
return new BroadcastIterator(this, this.#signal, this.join());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
dispose(): void {
|
|
337
|
+
this[Symbol.dispose]();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
[Symbol.dispose](): void {
|
|
341
|
+
// Stryker disable next-line ConditionalExpression: double-dispose re-clears empty collections, no observable effect
|
|
342
|
+
if (this.#disposer[Symbol.dispose]()) {
|
|
343
|
+
this.#signal[Symbol.dispose]();
|
|
344
|
+
this.#buffer.clear();
|
|
345
|
+
this.#cursors.clear();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Fn, MaybePromise } from './types.js';
|
|
2
|
+
import { isThenable, noop } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const ERR_BRAND = Symbol.for('evnty.ResultError');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export interface ResultError<E = unknown> {
|
|
10
|
+
readonly error: E;
|
|
11
|
+
readonly [ERR_BRAND]: true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ResultError<E>(this: ResultError<E>, error: E): void {
|
|
15
|
+
(this as { error: E }).error = error;
|
|
16
|
+
}
|
|
17
|
+
(ResultError.prototype as { [ERR_BRAND]: true })[ERR_BRAND] = true;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export function err<E>(error: E): ResultError<E> {
|
|
23
|
+
return new (ResultError as unknown as new (error: E) => ResultError<E>)(error);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
export function isErr(result: unknown): result is ResultError<unknown> {
|
|
30
|
+
return typeof result === 'object' && result !== null && (result as Record<symbol, boolean>)[ERR_BRAND] === true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export function isOk(result: unknown): boolean {
|
|
37
|
+
return typeof result !== 'object' || result === null || !(result as Record<symbol, boolean>)[ERR_BRAND];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export type DispatchResultItem<T> = MaybePromise<T> | ResultError;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export function unwrap<T>(results: DispatchResultItem<T>[]): MaybePromise<T>[] {
|
|
49
|
+
const len = results.length;
|
|
50
|
+
const unwrapped = new Array<MaybePromise<T>>(len);
|
|
51
|
+
for (let i = 0; i < len; i++) {
|
|
52
|
+
const r = results[i];
|
|
53
|
+
unwrapped[i] = isErr(r) ? Promise.reject(r.error) : r;
|
|
54
|
+
}
|
|
55
|
+
return unwrapped;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function resolveMaybePromises<T>(items: MaybePromise<T>[], asyncIndices: number[]): Promise<T[]> {
|
|
59
|
+
const pending = new Array<PromiseLike<T>>(asyncIndices.length);
|
|
60
|
+
for (let j = 0; j < asyncIndices.length; j++) {
|
|
61
|
+
pending[j] = items[asyncIndices[j]] as PromiseLike<T>;
|
|
62
|
+
}
|
|
63
|
+
const resolved = await Promise.all(pending);
|
|
64
|
+
for (let j = 0; j < asyncIndices.length; j++) {
|
|
65
|
+
items[asyncIndices[j]] = resolved[j];
|
|
66
|
+
}
|
|
67
|
+
return items as T[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveAll<T>(results: DispatchResultItem<T>[]): T[] | Promise<T[]> {
|
|
71
|
+
const len = results.length;
|
|
72
|
+
if (len === 0) return results as T[];
|
|
73
|
+
|
|
74
|
+
let firstError: unknown;
|
|
75
|
+
let hasError = false;
|
|
76
|
+
let asyncIndices: number[] | null = null;
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < len; i++) {
|
|
79
|
+
const r = results[i];
|
|
80
|
+
if (isErr(r)) {
|
|
81
|
+
if (!hasError) {
|
|
82
|
+
hasError = true;
|
|
83
|
+
firstError = r.error;
|
|
84
|
+
}
|
|
85
|
+
} else if (isThenable(r)) {
|
|
86
|
+
(asyncIndices ??= []).push(i);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (hasError) {
|
|
91
|
+
if (asyncIndices !== null) {
|
|
92
|
+
for (let j = 0; j < asyncIndices.length; j++) {
|
|
93
|
+
(results[asyncIndices[j]] as PromiseLike<T>).then(noop, noop);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return Promise.reject(firstError);
|
|
97
|
+
}
|
|
98
|
+
if (asyncIndices === null) return results as T[];
|
|
99
|
+
|
|
100
|
+
return resolveMaybePromises(results as MaybePromise<T>[], asyncIndices);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function settleAll<T>(results: DispatchResultItem<T>[]): PromiseSettledResult<T>[] | Promise<PromiseSettledResult<T>[]> {
|
|
104
|
+
const len = results.length;
|
|
105
|
+
if (len === 0) return [] as PromiseSettledResult<T>[];
|
|
106
|
+
|
|
107
|
+
let asyncIndices: number[] | null = null;
|
|
108
|
+
const settled = new Array<MaybePromise<PromiseSettledResult<T>>>(len);
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < len; i++) {
|
|
111
|
+
const r = results[i];
|
|
112
|
+
if (isErr(r)) {
|
|
113
|
+
settled[i] = { status: 'rejected', reason: r.error };
|
|
114
|
+
} else if (isThenable(r)) {
|
|
115
|
+
(asyncIndices ??= []).push(i);
|
|
116
|
+
settled[i] = r.then(
|
|
117
|
+
(value): PromiseFulfilledResult<T> => ({ status: 'fulfilled', value }),
|
|
118
|
+
(reason: unknown): PromiseRejectedResult => ({ status: 'rejected', reason }),
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
settled[i] = { status: 'fulfilled', value: r };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (asyncIndices === null) return settled as PromiseSettledResult<T>[];
|
|
126
|
+
|
|
127
|
+
return resolveMaybePromises(settled, asyncIndices);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Wraps an array of values or promises (typically listener results) and provides batch resolution.
|
|
132
|
+
*
|
|
133
|
+
* @template T
|
|
134
|
+
*/
|
|
135
|
+
export class DispatchResult<T> implements PromiseLike<T[]> {
|
|
136
|
+
#results: DispatchResultItem<T>[];
|
|
137
|
+
|
|
138
|
+
readonly [Symbol.toStringTag] = 'DispatchResult';
|
|
139
|
+
|
|
140
|
+
constructor(results: DispatchResultItem<T>[]) {
|
|
141
|
+
this.#results = results;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
then<TResult1 = T, TResult2 = never>(
|
|
145
|
+
onfulfilled?: Fn<[T[]], MaybePromise<TResult1>> | null,
|
|
146
|
+
onrejected?: Fn<[any], MaybePromise<TResult2>> | null,
|
|
147
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
148
|
+
const resolved = this.all();
|
|
149
|
+
if (resolved instanceof Promise) return resolved.then(onfulfilled, onrejected);
|
|
150
|
+
return Promise.resolve(resolved).then(onfulfilled, onrejected);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolves all listener results, rejecting if any promise rejects or any ResultError exists.
|
|
155
|
+
*/
|
|
156
|
+
all(): T[] | Promise<T[]> {
|
|
157
|
+
return resolveAll(this.#results);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Waits for all listener results to settle, regardless of fulfillment or rejection.
|
|
162
|
+
*/
|
|
163
|
+
settled(): PromiseSettledResult<T>[] | Promise<PromiseSettledResult<T>[]> {
|
|
164
|
+
return settleAll(this.#results);
|
|
165
|
+
}
|
|
166
|
+
}
|