@johnhenry/iteration 0.0.0 → 0.0.1

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 (49) hide show
  1. package/dist/async-channel.d.ts +24 -4
  2. package/dist/async-channel.d.ts.map +1 -1
  3. package/dist/async-channel.js +41 -38
  4. package/dist/async-channel.js.map +1 -1
  5. package/dist/combine.d.ts +146 -0
  6. package/dist/combine.d.ts.map +1 -0
  7. package/dist/combine.js +353 -0
  8. package/dist/combine.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/internal.d.ts +66 -0
  14. package/dist/internal.d.ts.map +1 -0
  15. package/dist/internal.js +134 -0
  16. package/dist/internal.js.map +1 -0
  17. package/dist/iterator-tools.d.ts.map +1 -1
  18. package/dist/iterator-tools.js +38 -18
  19. package/dist/iterator-tools.js.map +1 -1
  20. package/dist/itertools.d.ts.map +1 -1
  21. package/dist/itertools.js +42 -24
  22. package/dist/itertools.js.map +1 -1
  23. package/dist/tee.d.ts +24 -2
  24. package/dist/tee.d.ts.map +1 -1
  25. package/dist/tee.js +175 -2
  26. package/dist/tee.js.map +1 -1
  27. package/dist/time.d.ts +69 -0
  28. package/dist/time.d.ts.map +1 -0
  29. package/dist/time.js +234 -0
  30. package/dist/time.js.map +1 -0
  31. package/dist/transduce.d.ts +49 -0
  32. package/dist/transduce.d.ts.map +1 -1
  33. package/dist/transduce.js +27 -1
  34. package/dist/transduce.js.map +1 -1
  35. package/dist/transducers.d.ts.map +1 -1
  36. package/dist/transducers.js +32 -14
  37. package/dist/transducers.js.map +1 -1
  38. package/package.json +10 -2
  39. package/readme.md +304 -14
  40. package/src/async-channel.ts +63 -47
  41. package/src/combine.ts +441 -0
  42. package/src/index.ts +2 -0
  43. package/src/internal.ts +164 -0
  44. package/src/iterator-tools.ts +35 -20
  45. package/src/itertools.ts +36 -23
  46. package/src/tee.ts +202 -2
  47. package/src/time.ts +262 -0
  48. package/src/transduce.ts +91 -1
  49. package/src/transducers.ts +34 -19
package/src/combine.ts ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Combinators for live values and events: merge several async sources,
3
+ * combine their latest values, sample one by another, and conflate a fast
4
+ * source down to its newest value.
5
+ *
6
+ * Every combinator here:
7
+ * - accepts async or sync iterables as inputs;
8
+ * - takes an optional `{ signal }`; on abort it rejects with the signal's
9
+ * reason (an "AbortError" DOMException by default);
10
+ * - closes (`return()`) every input that has not ended when it finishes,
11
+ * when an input errors, when the signal aborts, and when the consumer
12
+ * exits early (`break`, or calling `return()` on it). An input with a
13
+ * `next()` still pending is closed without waiting for that `next()`.
14
+ *
15
+ * The "latest value" combinators (combineLatestAsync, withLatestFromAsync,
16
+ * latestAsync, sampleAsync) drain some inputs eagerly: they keep pulling
17
+ * from them whether or not the consumer is reading, and keep only the
18
+ * newest value. Like every generator in this library they start when the
19
+ * consumer first calls `next()`.
20
+ */
21
+
22
+ import { type SignalOptions, throwIfAborted } from "./abort.ts";
23
+ import {
24
+ type AnyIterable,
25
+ closeAll,
26
+ createFailure,
27
+ type Entry,
28
+ entryOf,
29
+ type Failure,
30
+ listenAbort,
31
+ pull,
32
+ Wakeup,
33
+ } from "./internal.ts";
34
+
35
+ /** The value type of an async or sync iterable. */
36
+ export type IteratedValue<I> = I extends AsyncIterable<infer V> ? V : I extends Iterable<infer V> ? V : never;
37
+
38
+ /** Inputs for combineLatestAsync / withLatestFromAsync: an array or a record of iterables. */
39
+ export type IterableShape = readonly AnyIterable<unknown>[] | { readonly [key: string]: AnyIterable<unknown> };
40
+
41
+ /** A snapshot with the same shape as its inputs: each iterable replaced by its latest value. */
42
+ export type LatestOf<I> = { -readonly [K in keyof I]: IteratedValue<I[K]> };
43
+
44
+ const isOptions = (value: unknown): boolean =>
45
+ typeof value === "object" &&
46
+ value !== null &&
47
+ !(Symbol.iterator in value) &&
48
+ !(Symbol.asyncIterator in (value as object));
49
+
50
+ /**
51
+ * Pull from an entry eagerly until it ends, errors, or `isStopped()`:
52
+ * `onValue` gets each value, `onEnd` runs once it ends, and an error fails
53
+ * the whole combinator.
54
+ */
55
+ const pump = async <T>(
56
+ entry: Entry<T>,
57
+ isStopped: () => boolean,
58
+ onValue: (value: T) => void,
59
+ onEnd: () => void,
60
+ failure: Failure,
61
+ ): Promise<void> => {
62
+ try {
63
+ while (!isStopped()) {
64
+ const result = await pull(entry);
65
+ if (isStopped()) return;
66
+ if (result.done) {
67
+ onEnd();
68
+ return;
69
+ }
70
+ onValue(result.value);
71
+ }
72
+ } catch (error) {
73
+ failure.fail(error);
74
+ }
75
+ };
76
+
77
+ /** Split an array-or-record of inputs into keys and iterables. */
78
+ const shapeOf = (inputs: IterableShape): { isArray: boolean; keys: string[]; iterables: AnyIterable<unknown>[] } => {
79
+ if (Array.isArray(inputs)) {
80
+ return { isArray: true, keys: inputs.map((_, i) => String(i)), iterables: [...inputs] };
81
+ }
82
+ const keys = Object.keys(inputs);
83
+ return {
84
+ isArray: false,
85
+ keys,
86
+ iterables: keys.map((key) => (inputs as Record<string, AnyIterable<unknown>>)[key] as AnyIterable<unknown>),
87
+ };
88
+ };
89
+
90
+ const snapshotOf = (isArray: boolean, keys: string[], values: unknown[]): any => {
91
+ if (isArray) return values.slice();
92
+ const out: Record<string, unknown> = {};
93
+ keys.forEach((key, i) => {
94
+ out[key] = values[i];
95
+ });
96
+ return out;
97
+ };
98
+
99
+ /**
100
+ * Merge several (async or sync) iterables into one, yielding each value as
101
+ * it arrives from any input. Ends when every input has ended.
102
+ *
103
+ * - Lazy: each input has at most one `next()` outstanding, and the next
104
+ * value is requested from an input only after its previous value has
105
+ * been consumed. Inputs that are ready at the same time are served in
106
+ * rotation, so a fast or synchronous input cannot starve the others.
107
+ * - An error from any input closes the others and is rethrown.
108
+ * - Consumer exit or abort closes every input.
109
+ *
110
+ * Pass `{ signal }` as the last argument.
111
+ * @kind function
112
+ * @name mergeAsync
113
+ * @example
114
+ * ```javascript
115
+ * for await (const event of mergeAsync(clicks, keys, { signal })) { ... }
116
+ * ```
117
+ */
118
+ export function mergeAsync<T>(...inputs: Array<AnyIterable<T>>): AsyncGenerator<T>;
119
+ export function mergeAsync<T>(...args: [...inputs: Array<AnyIterable<T>>, options: SignalOptions]): AsyncGenerator<T>;
120
+ export function mergeAsync<T>(...args: Array<AnyIterable<T> | SignalOptions>): AsyncGenerator<T> {
121
+ const last = args[args.length - 1];
122
+ const options = isOptions(last) ? (args.pop() as SignalOptions) : {};
123
+ return mergeImpl(args as Array<AnyIterable<T>>, options);
124
+ }
125
+
126
+ const mergeImpl = async function* <T>(inputs: Array<AnyIterable<T>>, { signal }: SignalOptions): AsyncGenerator<T> {
127
+ throwIfAborted(signal);
128
+ const entries = inputs.map((input) => entryOf(input));
129
+ const failure = createFailure();
130
+ const unlisten = listenAbort(signal, (reason) => failure.fail(reason));
131
+ // The outstanding next() of each input, tagged with its index.
132
+ const pending = new Map<number, Promise<[number, IteratorResult<T>]>>();
133
+ const request = (i: number) => {
134
+ const tagged = pull(entries[i] as Entry<T>).then((result): [number, IteratorResult<T>] => [i, result]);
135
+ tagged.catch(() => {}); // rejections are observed through the race below
136
+ pending.set(i, tagged);
137
+ };
138
+ try {
139
+ entries.forEach((_, i) => {
140
+ request(i);
141
+ });
142
+ let lastWinner = -1;
143
+ while (pending.size > 0) {
144
+ if (failure.failed) throw failure.error;
145
+ // Rotate the race so the input after the last winner goes first:
146
+ // Promise.race favours the earliest already-settled promise.
147
+ const rank = (k: number) => (k - lastWinner - 1 + entries.length) % entries.length;
148
+ const order = [...pending.keys()].sort((a, b) => rank(a) - rank(b));
149
+ const [i, result] = await Promise.race([...order.map((k) => pending.get(k)!), failure.promise]);
150
+ pending.delete(i);
151
+ lastWinner = i;
152
+ if (result.done) continue;
153
+ yield result.value;
154
+ request(i);
155
+ }
156
+ if (failure.failed) throw failure.error;
157
+ } finally {
158
+ unlisten();
159
+ await closeAll(entries);
160
+ }
161
+ };
162
+
163
+ export interface CombineLatestOptions<I> extends SignalOptions {
164
+ /**
165
+ * Starting values for some or all inputs (same shape as the inputs), so a
166
+ * snapshot can be emitted before those inputs have yielded. They are not
167
+ * emitted on their own: the first snapshot still waits for an input to
168
+ * yield.
169
+ */
170
+ initial?: Partial<LatestOf<I>>;
171
+ /**
172
+ * `"all"` (default): end once every input has ended. `"any"`: end as
173
+ * soon as any input ends. Either way, an input that ends without ever
174
+ * having a value (and no `initial` for it) ends the output at once,
175
+ * since no complete snapshot can ever be made.
176
+ */
177
+ endOn?: "all" | "any";
178
+ }
179
+
180
+ /**
181
+ * Combine the latest values of several inputs. `inputs` is an array or a
182
+ * record of (async or sync) iterables; each output is a snapshot of the
183
+ * same shape holding every input's latest value. A snapshot is emitted
184
+ * whenever an input yields, once every input has a value (from the input
185
+ * itself or from `initial`).
186
+ *
187
+ * Latest-wins: inputs are drained eagerly, and while the consumer is busy
188
+ * the snapshots that would have been emitted coalesce into one, holding
189
+ * the newest values. Every snapshot is a fresh object.
190
+ *
191
+ * An input error closes the others and is rethrown (a pending snapshot is
192
+ * dropped). Consumer exit or abort closes every input.
193
+ * @kind function
194
+ * @name combineLatestAsync
195
+ * @example
196
+ * ```javascript
197
+ * for await (const { x, y } of combineLatestAsync({ x: xs, y: ys })) { ... }
198
+ * ```
199
+ */
200
+ export const combineLatestAsync = async function* <const I extends IterableShape>(
201
+ inputs: I,
202
+ { signal, initial, endOn = "all" }: CombineLatestOptions<I> = {},
203
+ ): AsyncGenerator<LatestOf<I>> {
204
+ throwIfAborted(signal);
205
+ const { isArray, keys, iterables } = shapeOf(inputs);
206
+ const n = keys.length;
207
+ const values: unknown[] = new Array(n);
208
+ const has: boolean[] = new Array(n).fill(false);
209
+ if (initial) {
210
+ keys.forEach((key, i) => {
211
+ if (Object.hasOwn(initial, key)) {
212
+ values[i] = (initial as Record<string, unknown>)[key];
213
+ has[i] = true;
214
+ }
215
+ });
216
+ }
217
+ const entries = iterables.map((iterable) => entryOf(iterable));
218
+ const failure = createFailure();
219
+ const wakeup = new Wakeup();
220
+ const unlisten = listenAbort(signal, (reason) => {
221
+ failure.fail(reason);
222
+ wakeup.wake();
223
+ });
224
+ let stopped = false;
225
+ let dirty = false;
226
+ let ended = n === 0;
227
+ try {
228
+ entries.forEach((entry, i) => {
229
+ void pump(
230
+ entry,
231
+ () => stopped,
232
+ (value) => {
233
+ values[i] = value;
234
+ has[i] = true;
235
+ dirty = true;
236
+ wakeup.wake();
237
+ },
238
+ () => {
239
+ if (endOn === "any" || !has[i] || entries.every((e) => e.done)) ended = true;
240
+ wakeup.wake();
241
+ },
242
+ failure,
243
+ );
244
+ });
245
+ while (true) {
246
+ if (failure.failed) throw failure.error;
247
+ if (dirty && has.every(Boolean)) {
248
+ dirty = false;
249
+ yield snapshotOf(isArray, keys, values);
250
+ continue;
251
+ }
252
+ if (ended) return;
253
+ await Promise.race([wakeup.wait(), failure.promise]);
254
+ }
255
+ } finally {
256
+ stopped = true;
257
+ unlisten();
258
+ await closeAll(entries);
259
+ }
260
+ };
261
+
262
+ /**
263
+ * Pair each value of `source` with the latest value of each of `others`
264
+ * (an array or a record of iterables): yields `[value, snapshot]`, where
265
+ * `snapshot` has the same shape as `others`. This is the "an event samples
266
+ * values" pattern.
267
+ *
268
+ * `source` is pulled lazily, as the consumer reads; `others` are drained
269
+ * eagerly. Source values that arrive before every one of `others` has a
270
+ * value are dropped. An input in `others` that ends keeps its last value.
271
+ * Ends when `source` ends. An error from any input closes the rest and is
272
+ * rethrown; consumer exit or abort closes every input.
273
+ * @kind function
274
+ * @name withLatestFromAsync
275
+ * @example
276
+ * ```javascript
277
+ * for await (const [click, { price }] of withLatestFromAsync(clicks, { price: prices })) { ... }
278
+ * ```
279
+ */
280
+ export const withLatestFromAsync = async function* <T, const I extends IterableShape>(
281
+ source: AnyIterable<T>,
282
+ others: I,
283
+ { signal }: SignalOptions = {},
284
+ ): AsyncGenerator<[T, LatestOf<I>]> {
285
+ throwIfAborted(signal);
286
+ const { isArray, keys, iterables } = shapeOf(others);
287
+ const values: unknown[] = new Array(keys.length);
288
+ const has: boolean[] = new Array(keys.length).fill(false);
289
+ const src = entryOf(source);
290
+ const entries = iterables.map((iterable) => entryOf(iterable));
291
+ const failure = createFailure();
292
+ const unlisten = listenAbort(signal, (reason) => failure.fail(reason));
293
+ let stopped = false;
294
+ try {
295
+ entries.forEach((entry, i) => {
296
+ void pump(
297
+ entry,
298
+ () => stopped,
299
+ (value) => {
300
+ values[i] = value;
301
+ has[i] = true;
302
+ },
303
+ () => {},
304
+ failure,
305
+ );
306
+ });
307
+ while (true) {
308
+ if (failure.failed) throw failure.error;
309
+ const result = await Promise.race([pull(src), failure.promise]);
310
+ if (result.done) return;
311
+ if (failure.failed) throw failure.error;
312
+ if (has.every(Boolean)) yield [result.value, snapshotOf(isArray, keys, values)];
313
+ }
314
+ } finally {
315
+ stopped = true;
316
+ unlisten();
317
+ await closeAll([src, ...entries]);
318
+ }
319
+ };
320
+
321
+ /**
322
+ * Conflate a source down to its newest value. The source is drained
323
+ * eagerly; only the newest value not yet read is kept. Each `next()`
324
+ * returns that value, or waits for the next one. A value is returned at
325
+ * most once.
326
+ *
327
+ * Ends once the source has ended and its last value has been read. A source
328
+ * error is rethrown on the next read (an unread value is dropped). Closing
329
+ * the result, or aborting, closes the source.
330
+ * @kind function
331
+ * @name latestAsync
332
+ * @example
333
+ * ```javascript
334
+ * for await (const position of latestAsync(pointerMoves)) {
335
+ * await render(position); // never falls behind: stale positions are skipped
336
+ * }
337
+ * ```
338
+ */
339
+ export const latestAsync = async function* <T>(
340
+ iterable: AnyIterable<T>,
341
+ { signal }: SignalOptions = {},
342
+ ): AsyncGenerator<T> {
343
+ throwIfAborted(signal);
344
+ const entry = entryOf(iterable);
345
+ const failure = createFailure();
346
+ const wakeup = new Wakeup();
347
+ const unlisten = listenAbort(signal, (reason) => {
348
+ failure.fail(reason);
349
+ wakeup.wake();
350
+ });
351
+ let stopped = false;
352
+ let fresh = false;
353
+ let ended = false;
354
+ let latest: T | undefined;
355
+ try {
356
+ void pump(
357
+ entry,
358
+ () => stopped,
359
+ (value) => {
360
+ latest = value;
361
+ fresh = true;
362
+ wakeup.wake();
363
+ },
364
+ () => {
365
+ ended = true;
366
+ wakeup.wake();
367
+ },
368
+ failure,
369
+ );
370
+ while (true) {
371
+ if (failure.failed) throw failure.error;
372
+ if (fresh) {
373
+ const value = latest as T;
374
+ fresh = false;
375
+ latest = undefined; // don't retain a value that has been handed out
376
+ yield value;
377
+ continue;
378
+ }
379
+ if (ended) return;
380
+ await Promise.race([wakeup.wait(), failure.promise]);
381
+ }
382
+ } finally {
383
+ stopped = true;
384
+ unlisten();
385
+ await closeAll([entry]);
386
+ }
387
+ };
388
+
389
+ /**
390
+ * On each value from `trigger`, yield the latest value of `source`, if it
391
+ * has produced one yet. The same source value is yielded again on every
392
+ * trigger until the source produces a new one.
393
+ *
394
+ * `source` is drained eagerly; `trigger` is pulled lazily, as the consumer
395
+ * reads. Ends when `trigger` ends (a source that ends keeps its last
396
+ * value). An error from either closes the other and is rethrown; consumer
397
+ * exit or abort closes both.
398
+ * @kind function
399
+ * @name sampleAsync
400
+ * @example
401
+ * ```javascript
402
+ * for await (const position of sampleAsync(pointerMoves, animationFrames)) { ... }
403
+ * ```
404
+ */
405
+ export const sampleAsync = async function* <T>(
406
+ source: AnyIterable<T>,
407
+ trigger: AnyIterable<unknown>,
408
+ { signal }: SignalOptions = {},
409
+ ): AsyncGenerator<T> {
410
+ throwIfAborted(signal);
411
+ const src = entryOf(source);
412
+ const trig = entryOf(trigger);
413
+ const failure = createFailure();
414
+ const unlisten = listenAbort(signal, (reason) => failure.fail(reason));
415
+ let stopped = false;
416
+ let has = false;
417
+ let latest: T | undefined;
418
+ try {
419
+ void pump(
420
+ src,
421
+ () => stopped,
422
+ (value) => {
423
+ latest = value;
424
+ has = true;
425
+ },
426
+ () => {},
427
+ failure,
428
+ );
429
+ while (true) {
430
+ if (failure.failed) throw failure.error;
431
+ const result = await Promise.race([pull(trig), failure.promise]);
432
+ if (result.done) return;
433
+ if (failure.failed) throw failure.error;
434
+ if (has) yield latest as T;
435
+ }
436
+ } finally {
437
+ stopped = true;
438
+ unlisten();
439
+ await closeAll([src, trig]);
440
+ }
441
+ };
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./abort.ts";
2
2
  export * from "./async-channel.ts";
3
+ export * from "./combine.ts";
3
4
  export * from "./concurrency.ts";
4
5
  export * from "./consumers.ts";
5
6
  export * from "./count.ts";
@@ -9,6 +10,7 @@ export * from "./is-iterator.ts";
9
10
  export * from "./iterator-tools.ts";
10
11
  export * from "./itertools.ts";
11
12
  export * from "./tee.ts";
13
+ export * from "./time.ts";
12
14
  export * from "./transduce.ts";
13
15
 
14
16
  import * as channelDecorators from "./channel-decorators.ts";
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Internal plumbing shared by combine.ts, time.ts and the zip fixes.
3
+ * Not re-exported from index.ts and not in the package `exports` map.
4
+ * @ignore
5
+ */
6
+
7
+ export type AnyIterable<T> = AsyncIterable<T> | Iterable<T>;
8
+ export type AnyIterator<T> = AsyncIterator<T> | Iterator<T>;
9
+
10
+ const noop = (): void => {};
11
+
12
+ /** Get an iterator from an async or sync iterable (async preferred). */
13
+ export const getIterator = <T>(iterable: AnyIterable<T>): AnyIterator<T> =>
14
+ Symbol.asyncIterator in Object(iterable)
15
+ ? (iterable as AsyncIterable<T>)[Symbol.asyncIterator]()
16
+ : (iterable as Iterable<T>)[Symbol.iterator]();
17
+
18
+ /** The reason an aborted signal carries ("AbortError" DOMException by default). */
19
+ export const abortReason = (signal: AbortSignal): unknown =>
20
+ signal.reason ?? new DOMException("This operation was aborted", "AbortError");
21
+
22
+ /**
23
+ * Call `onAbort(reason)` once when `signal` aborts (immediately if it
24
+ * already has). Returns a function that removes the listener.
25
+ */
26
+ export const listenAbort = (signal: AbortSignal | undefined, onAbort: (reason: unknown) => void): (() => void) => {
27
+ if (!signal) return noop;
28
+ if (signal.aborted) {
29
+ onAbort(abortReason(signal));
30
+ return noop;
31
+ }
32
+ const listener = () => onAbort(abortReason(signal));
33
+ signal.addEventListener("abort", listener, { once: true });
34
+ return () => signal.removeEventListener("abort", listener);
35
+ };
36
+
37
+ /**
38
+ * A one-shot failure latch: `fail(error)` records the first error and
39
+ * rejects `promise` with it. `promise` is pre-handled, so it never surfaces
40
+ * as an unhandled rejection; race it against a pending read to wake up on
41
+ * failure.
42
+ */
43
+ export interface Failure {
44
+ failed: boolean;
45
+ error: unknown;
46
+ promise: Promise<never>;
47
+ fail(error: unknown): void;
48
+ }
49
+
50
+ export const createFailure = (): Failure => {
51
+ let reject!: (error: unknown) => void;
52
+ const promise = new Promise<never>((_, r) => {
53
+ reject = r;
54
+ });
55
+ promise.catch(noop);
56
+ const failure: Failure = {
57
+ failed: false,
58
+ error: undefined,
59
+ promise,
60
+ fail(error) {
61
+ if (failure.failed) return;
62
+ failure.failed = true;
63
+ failure.error = error;
64
+ reject(error);
65
+ },
66
+ };
67
+ return failure;
68
+ };
69
+
70
+ /** A reusable wake-up: `wait()` resolves on the next `wake()`. */
71
+ export class Wakeup {
72
+ #promise: Promise<void> | undefined;
73
+ #resolve: (() => void) | undefined;
74
+ wait(): Promise<void> {
75
+ if (!this.#promise) {
76
+ this.#promise = new Promise<void>((resolve) => {
77
+ this.#resolve = resolve;
78
+ });
79
+ }
80
+ return this.#promise;
81
+ }
82
+ wake(): void {
83
+ const resolve = this.#resolve;
84
+ this.#promise = undefined;
85
+ this.#resolve = undefined;
86
+ resolve?.();
87
+ }
88
+ }
89
+
90
+ /** An input iterator plus the state needed to close it correctly. */
91
+ export interface Entry<T> {
92
+ it: AnyIterator<T>;
93
+ /** Ended, errored, or already closed: never call return() on it. */
94
+ done: boolean;
95
+ /** A next() call is outstanding. */
96
+ inFlight: boolean;
97
+ }
98
+
99
+ export const entryOf = <T>(iterable: AnyIterable<T>): Entry<T> => ({
100
+ it: getIterator(iterable),
101
+ done: false,
102
+ inFlight: false,
103
+ });
104
+
105
+ /**
106
+ * Call `next()` on an entry, tracking `inFlight`/`done`. A synchronous
107
+ * throw from `next()` becomes a rejection. An input whose `next()` rejects
108
+ * or throws is marked done (the iterator protocol does not call return()
109
+ * on an iterator that threw).
110
+ */
111
+ export const pull = <T>(entry: Entry<T>): Promise<IteratorResult<T>> => {
112
+ entry.inFlight = true;
113
+ return new Promise<IteratorResult<T>>((resolve) => resolve(entry.it.next())).then(
114
+ (result) => {
115
+ entry.inFlight = false;
116
+ if (result.done) entry.done = true;
117
+ return result;
118
+ },
119
+ (error: unknown) => {
120
+ entry.inFlight = false;
121
+ entry.done = true;
122
+ throw error;
123
+ },
124
+ );
125
+ };
126
+
127
+ /**
128
+ * Close (`return()`) every entry that has not ended. Entries with no
129
+ * outstanding `next()` are awaited, so their cleanup has finished when
130
+ * this resolves. Entries with a `next()` still in flight are closed
131
+ * fire-and-forget: an async generator queues `return()` behind its pending
132
+ * `next()`, so awaiting it would hang on a stalled source (the case that
133
+ * aborting is for). Errors from `return()` are swallowed.
134
+ */
135
+ export const closeAll = async (entries: Iterable<Entry<unknown>>): Promise<void> => {
136
+ const waits: Array<Promise<unknown>> = [];
137
+ for (const entry of entries) {
138
+ if (entry.done) continue;
139
+ entry.done = true;
140
+ if (typeof entry.it.return !== "function") continue;
141
+ let result: unknown;
142
+ try {
143
+ result = entry.it.return();
144
+ } catch {
145
+ continue;
146
+ }
147
+ const settled = Promise.resolve(result).then(noop, noop);
148
+ if (!entry.inFlight) waits.push(settled);
149
+ }
150
+ await Promise.all(waits);
151
+ };
152
+
153
+ /** Synchronous counterpart of closeAll, for sync iterators. */
154
+ export const closeAllSync = (entries: Iterable<{ it: Iterator<unknown>; done: boolean }>): void => {
155
+ for (const entry of entries) {
156
+ if (entry.done) continue;
157
+ entry.done = true;
158
+ try {
159
+ entry.it.return?.();
160
+ } catch {
161
+ // Cleanup is best-effort; the original completion (or error) wins.
162
+ }
163
+ }
164
+ };
@@ -1,4 +1,4 @@
1
- import { isAsyncIterator } from "./is-iterator.ts";
1
+ import { closeAll, closeAllSync, entryOf, pull } from "./internal.ts";
2
2
 
3
3
  /**
4
4
  * Sentinel returned by a reducer step to stop consumption of the source
@@ -221,17 +221,30 @@ export const conjoinAsync = async function* <T>(
221
221
  * @returns an iterator who's members are the members of the given iterators zipped sequencially
222
222
  */
223
223
  export const zipSync = function* <T>(...iteratorList: Array<Iterable<T>>): Generator<T[]> {
224
- const generators = iteratorList.map((iterator) => iterator[Symbol.iterator]());
225
- outer: while (true) {
226
- const result: T[] = [];
227
- for (const generator of generators) {
228
- const { value, done } = generator.next();
229
- if (done) {
230
- break outer;
224
+ const entries = iteratorList.map((iterator) => ({ it: iterator[Symbol.iterator](), done: false }));
225
+ try {
226
+ outer: while (true) {
227
+ const result: T[] = [];
228
+ for (const entry of entries) {
229
+ let step: IteratorResult<T>;
230
+ try {
231
+ step = entry.it.next();
232
+ } catch (error) {
233
+ entry.done = true; // an iterator that threw is not closed again
234
+ throw error;
235
+ }
236
+ if (step.done) {
237
+ entry.done = true;
238
+ break outer;
239
+ }
240
+ result.push(step.value);
231
241
  }
232
- result.push(value);
242
+ yield result;
233
243
  }
234
- yield result;
244
+ } finally {
245
+ // Close every input that has not ended -- when the shortest input ends,
246
+ // when an input throws, and when the consumer exits early.
247
+ closeAllSync(entries);
235
248
  }
236
249
  };
237
250
 
@@ -248,17 +261,19 @@ export const zipSync = function* <T>(...iteratorList: Array<Iterable<T>>): Gener
248
261
  export const zipAsync = async function* <T>(
249
262
  ...iteratorList: Array<AsyncIterable<T> | Iterable<T>>
250
263
  ): AsyncGenerator<T[]> {
251
- const generators = iteratorList.map((iterator) =>
252
- isAsyncIterator(iterator)
253
- ? (iterator as AsyncIterable<T>)[Symbol.asyncIterator]()
254
- : (iterator as Iterable<T>)[Symbol.iterator](),
255
- );
256
- while (true) {
257
- const results = await Promise.all(generators.map((g) => g.next()));
258
- if (results.some(({ done }) => done)) {
259
- break;
264
+ const entries = iteratorList.map((iterator) => entryOf(iterator));
265
+ try {
266
+ while (true) {
267
+ const results = await Promise.all(entries.map((entry) => pull(entry)));
268
+ if (results.some(({ done }) => done)) {
269
+ break;
270
+ }
271
+ yield results.map(({ value }) => value as T);
260
272
  }
261
- yield results.map(({ value }) => value as T);
273
+ } finally {
274
+ // Close every input that has not ended -- when the shortest input ends,
275
+ // when an input rejects, and when the consumer exits early.
276
+ await closeAll(entries);
262
277
  }
263
278
  };
264
279