Proposal idea: `AsyncIterator.withResolvers` AsyncIterator outside resolver

We should have a helper like Promise.withResolvers() to add state from the outside.

A simple implementation would be something like this:

AsyncIterator.withResolvers ??= () => {
  let controller;
  let closed = false;
  const abortController = new AbortController();
  const signal = abortController.signal;

  const stream = new ReadableStream({
    start(controller_) {
      controller = controller_;
    },
    cancel(reason) {
      if (!closed) {
        controller.error(reason);
        closed = true;
      }
      abortController.abort(reason);
    },
  });
  const [values, complete] = (() => {
    const values = stream.values();
    const originalReturn = values.return;
    // for of .. break support 
    values.return = function (value) {
      if (!closed) {
        controller.close();
        closed = true;
      }
      abortController.abort();
      return originalReturn.call(this, value);
    };
    return [values, complete];
    function complete(value) {
      if (closed) return;
      controller.close();
      closed = true;
      abortController.abort();
      return originalReturn.call(values, value);
    }
  })();

  return {
    values,
    resolve: controller.enqueue.bind(controller),
    complete: () => complete(),
    reject: controller.error.bind(controller),
    signal,
  };
}

Explanation of AsyncIterator.withResolvers():

This function is a utility to generate an externally controllable asynchronous iterator. It allows you to retrieve values asynchronously using the for await...of construct.

Features:

  1. Asynchronous Iterator Generation: Generates an asynchronous iterator that can be used with the for await...of syntax.
  2. External Control: Through the resolve, complete, and reject properties, values can be added, completion can be signaled, and errors can be generated for the iterator.
  3. Early Iterator Termination Support: Ensures the iterator is properly cleaned up when it terminates early, such as by using break in a for await...of loop.

Return Value Properties:

  • values: The generated asynchronous iterator. Can be used with the for await...of syntax.
  • resolve: A function to add values to the iterator.
  • complete: A function to signal the successful completion of the iterator.
  • reject: A function to generate an error for the iterator.
  • signal: The AbortSignal to monitor for iterator cancellation.

Essentially, AsyncIterator.withResolvers() allows you to create an AsyncIterator that responds to external events (like button clicks or intervals) and signals (like abort signals). You can push values into it using resolve(), signal completion using complete(), or signal an error using reject().

Here's how to use it:

const { values, resolve, complete } = AsyncIterator.withResolvers();
buttonElement.addEventListener('click', resolve, { signal });
signal.addEventListener('abort', complete, {once: true});

for await (const event of values) {
  // click event...
}

playground example 1

const { values, resolve, complete } = AsyncIterator.withResolvers();
const clear = setInterval(resolve, 1000);
signal.addEventListener('abort', () => {
  clearInterval(clear);
  complete();
}, {once:true});

for await (const _ of values) {
  // interval ...
}

playground example 2

The properties returned by this method are resolve, reject, complete, and values, and their only intention is to match Promise.withResolver(), so I would appreciate it if you could let me know if there are any good names or anything missing.

This looks like a signal implementation instead of an async iterator implementation, right? Perhaps you are looking for GitHub - tc39/proposal-signals: A proposal to add signals to JavaScript.?

1 Like

Converting signals to for await ... of ... ...?

However, it seems that signals does not have complete.

signals watcher to for await ... of ... ... ?

I just want to create an AsyncIterator without using a generator like Promise.withResolver()...

openButton.addEventListener("click", async () => {
  const values = openDialog();
  log("open");
  for await (const i of values) {
    log(`${i}`);
    if (i === "value3") break;
  }
  log("close");
});

function openDialog() {
  const controller = new AbortController();
  const {
    values,
    resolve,
    complete,
    signal: innerSignal,
  } = AsyncIterator.withResolvers();
  const signal = AbortSignal.any([controller.signal, innerSignal]);
  dialog.showModal();
  // #region set value
  value1.addEventListener("click", () => resolve("value1"), { signal });
  value2.addEventListener("click", () => resolve("value2"), { signal });
  value3.addEventListener("click", () => resolve("value3"), { signal });
  // #endregion
  // #region close
  closeButton.addEventListener("click", () => controller.abort(), { signal });
  dialog.addEventListener("clise", () => controller.abort(), { signal });
  dialog.addEventListener("cancel", () => controller.abort(), { signal });
  signal.addEventListener("abort", () => {
    dialog.close();
    complete();
  });
  // #endregion
  return values;
}

playground example 3

for await ... of ... break support ...

It's no longer a simple implementation...

AsyncIterator.withResolvers ??= () => {
  let controller;
  let closed = false;
  const abortController = new AbortController();
  const signal = abortController.signal;

  const stream = new ReadableStream({
    start(controller_) {
      controller = controller_;
    },
    cancel(reason) {
      if (!closed) {
        controller.error(reason);
        closed = true;
      }
      abortController.abort(reason);
    },
  });
  const [values, complete] = (() => {
    const values = stream.values();
    const originalReturn = values.return;
    // for of .. break support 
    values.return = complete;
    return [values, complete];
    function complete(value) {
      if (closed) return;
      controller.close();
      closed = true;
      abortController.abort();
      return originalReturn.call(values, value);
    }
  })();

  return {
    values,
    resolve: controller.enqueue.bind(controller),
    complete: () => complete(),
    reject: controller.error.bind(controller),
    signal,
  };
}

In the original implementation, if you break from for await .. of, the call to complete would sometimes result in an error, so we have revised and updated it.