Repository: unjs/h3
Pull request #655 (2024-02-21 09:56:34)

feat(response): add `sendIterable` util
<!---
☝️ PR title should follow conventional commits (https://conventionalcommits.org) 
-->

### 🔗 Linked issue

https://github.com/unjs/h3/issues/581

### ❓ Type of change

<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->

- [ ] 📖 Documentation (updates to the documentation, readme, or JSdoc annotations)
- [ ] 🐞 Bug fix (a non-breaking change that fixes an issue)
- [ ] 👌 Enhancement (improving an existing functionality like performance)
- [x] ✨ New feature (a non-breaking change that adds functionality)
- [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

### 📚 Description


<!-- Describe your changes in detail -->
Introduces a new utility function `sendIterable`, relevant types and an additional helper function are also exposed.

Newly exposed API:

```ts
export function sendIterable<Value = unknown, Return = unknown>(
  event: H3Event,
  iterable: IterationSource<Value, Return>,
  serializer: IteratorSerializer<Value | Return> = serializeIterableValue
) : Promise<void> {/* snip */}

// Supported iterable and iterator types of `sendIterable`
export type IterationSource<Val, Ret = Val> =
  | Iterable<Val>
  | AsyncIterable<Val>
  | Iterator<Val, Ret | undefined>
  | AsyncIterator<Val, Ret | undefined>
  | (() => (
    | Iterator<Val, Ret | undefined>
    | AsyncIterator<Val, Ret | undefined>
  ));

// The response stream (event.node.res) is in byte-mode, so you the types of data that can be written is restricted.
type SendableValue = string | Buffer | Uint8Array;
// The `undefined` return value is a short-cut to not writing anything to the response stream, for example if the data type cannot be serialized.
export type IteratorSerializer<Value> = (value: Value) => SendableValue | undefined;

// Default serializer that turns types that are not directly writable to the underlying network stream into ones that are.
export function serializeIterableValue(value: unknown): SendableValue | undefined {/* snip */}
```

<!-- Why is this change required? What problem does it solve? -->

The new feature allows users of h3 (and by extension Nitro and Nuxt) to write generator functions (`function*(){}`) that produce a response piece-by-piece. For more reason on justification see the linked issue.

<!-- If it resolves an open issue, please link to the issue here. For example "Resolves #1337" -->

Resolves https://github.com/unjs/h3/issues/581

### 📝 Checklist

<!-- Put an `x` in all the boxes that apply. -->
<!-- If your change requires a documentation PR, please link it appropriately -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] I have linked an issue or discussion.
- [ ] I have updated the documentation accordingly.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/unjs/h3/pull/655?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs) Report
Attention: Patch coverage is `97.01493%` with `4 lines` in your changes are missing coverage. Please review.
> Project coverage is 77.90%. Comparing base [(`a58d7c9`)](https://app.codecov.io/gh/unjs/h3/commit/a58d7c977b30ef1782bb390a38fed3b28f8afca9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs) to head [(`62c3030`)](https://app.codecov.io/gh/unjs/h3/pull/655?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs).
> Report is 19 commits behind head on main.

| [Files](https://app.codecov.io/gh/unjs/h3/pull/655?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs) | Patch % | Lines |
|---|---|---|
| [src/utils/internal/iteratable.ts](https://app.codecov.io/gh/unjs/h3/pull/655?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs#diff-c3JjL3V0aWxzL2ludGVybmFsL2l0ZXJhdGFibGUudHM=) | 96.87% | [2 Missing :warning: ](https://app.codecov.io/gh/unjs/h3/pull/655?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs) |
| [src/utils/response.ts](https://app.codecov.io/gh/unjs/h3/pull/655?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs#diff-c3JjL3V0aWxzL3Jlc3BvbnNlLnRz) | 97.14% | [2 Missing :warning: ](https://app.codecov.io/gh/unjs/h3/pull/655?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #655      +/-   ##
==========================================
+ Coverage   77.83%   77.90%   +0.07%     
==========================================
  Files          47       53       +6     
  Lines        4286     4970     +684     
  Branches      611      654      +43     
==========================================
+ Hits         3336     3872     +536     
- Misses        933     1079     +146     
- Partials       17       19       +2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/unjs/h3/pull/655?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=unjs).


Patch:
diff --git a/docs/2.utils/2.reponse.md b/docs/2.utils/2.reponse.md
index a1b2acb..8342433 100644
--- a/docs/2.utils/2.reponse.md
+++ b/docs/2.utils/2.reponse.md
@@ -65,6 +65,35 @@ Remove a response header by name.
 Directly send a response to the client.
 **Note:** This function should be used only when you want to send a response directly without using the `h3` event. Normaly you can directly `return` a value inside event handlers.
 
+### `sendIterable(event, iterable)`
+
+Iterate a source of chunks and send back each chunk in order. Supports mixing async work toghether with emitting chunks.
+Each chunk must be a string or a buffer.
+For generator (yielding) functions, the returned value is treated the same as yielded values.
+
+**Example:**
+
+```ts
+sendIterable(event, work());
+async function* work() {
+  // Open document body
+  yield "<!DOCTYPE html>\n<html><body><h1>Executing...</h1><ol>\n";
+  // Do work ...
+  for (let i = 0; i < 1000) {
+    await delay(1000);
+    // Report progress
+    yield `<li>Completed job #`;
+    yield i;
+    yield `</li>\n`;
+  }
+  // Close out the report
+  return `</ol></body></html>`;
+}
+async function delay(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+```
+
 ### `sendNoContent(event, code?)`
 
 Respond with an empty payload.<br>
diff --git a/src/utils/internal/iteratable.ts b/src/utils/internal/iteratable.ts
new file mode 100644
index 0000000..68f9aa2
--- /dev/null
+++ b/src/utils/internal/iteratable.ts
@@ -0,0 +1,64 @@
+export type IterationSource<Val, Ret = Val> =
+  | Iterable<Val>
+  | AsyncIterable<Val>
+  | Iterator<Val, Ret | undefined>
+  | AsyncIterator<Val, Ret | undefined>
+  | (() =>
+      | Iterator<Val, Ret | undefined>
+      | AsyncIterator<Val, Ret | undefined>);
+
+type SendableValue = string | Buffer | Uint8Array;
+export type IteratorSerializer<Value> = (
+  value: Value,
+) => SendableValue | undefined;
+
+/**
+ * The default implementation for {@link sendIterable}'s `serializer` argument.
+ * It serializes values as follows:
+ * - Instances of {@link String}, {@link Uint8Array} and `undefined` are returned as-is.
+ * - Objects are serialized through {@link JSON.stringify}.
+ * - Functions are serialized as `undefined`.
+ * - Values of type boolean, number, bigint or symbol are serialized using their `toString` function.
+ *
+ * @param value - The value to serialize to either a string or Uint8Array.
+ */
+export function serializeIterableValue(
+  value: unknown,
+): SendableValue | undefined {
+  switch (typeof value) {
+    case "string": {
+      return value;
+    }
+    case "boolean":
+    case "number":
+    case "bigint":
+    case "symbol": {
+      return value.toString();
+    }
+    case "function":
+    case "undefined": {
+      return undefined;
+    }
+    case "object": {
+      if (value instanceof Uint8Array) {
+        return value;
+      }
+      return JSON.stringify(value);
+    }
+  }
+}
+
+export function coerceIterable<V, R>(
+  iterable: IterationSource<V, R>,
+): Iterator<V> | AsyncIterator<V> {
+  if (typeof iterable === "function") {
+    iterable = iterable();
+  }
+  if (Symbol.iterator in iterable) {
+    return iterable[Symbol.iterator]();
+  }
+  if (Symbol.asyncIterator in iterable) {
+    return iterable[Symbol.asyncIterator]();
+  }
+  return iterable;
+}
diff --git a/src/utils/response.ts b/src/utils/response.ts
index 48c27fe..8ee1fa1 100644
--- a/src/utils/response.ts
+++ b/src/utils/response.ts
@@ -7,6 +7,12 @@ import { MIMES } from "./consts";
 import { sanitizeStatusCode, sanitizeStatusMessage } from "./sanitize";
 import { splitCookiesString } from "./cookie";
 import { hasProp } from "./internal/object";
+import {
+  serializeIterableValue,
+  coerceIterable,
+  IterationSource,
+  IteratorSerializer,
+} from "./internal/iteratable";
 
 const defer =
   typeof setImmediate === "undefined" ? (fn: () => any) => fn() : setImmediate;
@@ -451,3 +457,67 @@ export function sendWebResponse(
   }
   return sendStream(event, response.body);
 }
+
+/**
+ * Iterate a source of chunks and send back each chunk in order.
+ * Supports mixing async work toghether with emitting chunks.
+ *
+ * Each chunk must be a string or a buffer.
+ *
+ * For generator (yielding) functions, the returned value is treated the same as yielded values.
+ *
+ * @param event - H3 event
+ * @param iterable - Iterator that produces chunks of the response.
+ * @param serializer - Function that converts values from the iterable into stream-compatible values.
+ * @template Value - Test
+ *
+ * @example
+ * sendIterable(event, work());
+ * async function* work() {
+ *   // Open document body
+ *   yield "<!DOCTYPE html>\n<html><body><h1>Executing...</h1><ol>\n";
+ *   // Do work ...
+ *   for (let i = 0; i < 1000) {
+ *     await delay(1000);
+ *     // Report progress
+ *     yield `<li>Completed job #`;
+ *     yield i;
+ *     yield `</li>\n`;
+ *   }
+ *   // Close out the report
+ *   return `</ol></body></html>`;
+ * }
+ * async function delay(ms) {
+ *   return new Promise(resolve => setTimeout(resolve, ms));
+ * }
+ */
+export function sendIterable<Value = unknown, Return = unknown>(
+  event: H3Event,
+  iterable: IterationSource<Value, Return>,
+  options?: {
+    serializer: IteratorSerializer<Value | Return>;
+  },
+): Promise<void> {
+  const serializer = options?.serializer ?? serializeIterableValue;
+  const iterator = coerceIterable(iterable);
+  return sendStream(
+    event,
+    new ReadableStream({
+      async pull(controller) {
+        const { value, done } = await iterator.next();
+        if (value !== undefined) {
+          const chunk = serializer(value);
+          if (chunk !== undefined) {
+            controller.enqueue(chunk);
+          }
+        }
+        if (done) {
+          controller.close();
+        }
+      },
+      cancel() {
+        iterator.return?.();
+      },
+    }),
+  );
+}


Test patch:
diff --git a/test/iteratable.test.ts b/test/iteratable.test.ts
new file mode 100644
index 0000000..43bc407
--- /dev/null
+++ b/test/iteratable.test.ts
@@ -0,0 +1,153 @@
+import { ReadableStream } from "node:stream/web";
+import supertest, { SuperTest, Test } from "supertest";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import {
+  createApp,
+  App,
+  toNodeListener,
+  eventHandler,
+  sendIterable,
+} from "../src";
+import { serializeIterableValue } from "../src/utils/internal/iteratable";
+
+describe("iteratable", () => {
+  let app: App;
+  let request: SuperTest<Test>;
+
+  beforeEach(() => {
+    app = createApp({ debug: false });
+    request = supertest(toNodeListener(app));
+  });
+
+  describe("serializeIterableValue", () => {
+    const exampleDate: Date = new Date(Date.UTC(2015, 6, 21, 3, 24, 54, 888));
+    it.each([
+      { value: "Hello, world!", output: "Hello, world!" },
+      { value: 123, output: "123" },
+      { value: 1n, output: "1" },
+      { value: true, output: "true" },
+      { value: false, output: "false" },
+      { value: undefined, output: undefined },
+      { value: null, output: "null" },
+      { value: exampleDate, output: JSON.stringify(exampleDate) },
+      { value: { field: 1 }, output: '{"field":1}' },
+      { value: [1, 2, 3], output: "[1,2,3]" },
+      { value: () => {}, output: undefined },
+      {
+        value: Buffer.from("Hello, world!"),
+        output: Buffer.from("Hello, world!"),
+      },
+      { value: Uint8Array.from([1, 2, 3]), output: Uint8Array.from([1, 2, 3]) },
+    ])("$value => $output", ({ value, output }) => {
+      const serialized = serializeIterableValue(value);
+      expect(serialized).toStrictEqual(output);
+    });
+  });
+
+  describe("sendIterable", () => {
+    it("sends empty body for an empty iterator", async () => {
+      app.use(eventHandler((event) => sendIterable(event, [])));
+      const result = await request.get("/");
+      expect(result.header["content-length"]).toBe("0");
+      expect(result.text).toBe("");
+    });
+
+    it("concatenates iterated values", async () => {
+      app.use(eventHandler((event) => sendIterable(event, ["a", "b", "c"])));
+      const result = await request.get("/");
+      expect(result.text).toBe("abc");
+    });
+
+    describe("iterable support", () => {
+      it.each([
+        { type: "Array", iterable: ["the-value"] },
+        { type: "Set", iterable: new Set(["the-value"]) },
+        {
+          type: "Map.keys()",
+          iterable: new Map([["the-value", "unused"]]).keys(),
+        },
+        {
+          type: "Map.values()",
+          iterable: new Map([["unused", "the-value"]]).values(),
+        },
+        {
+          type: "Iterator object",
+          iterable: { next: () => ({ value: "the-value", done: true }) },
+        },
+        {
+          type: "AsyncIterator object",
+          iterable: {
+            next: () => Promise.resolve({ value: "the-value", done: true }),
+          },
+        },
+        {
+          type: "Generator (yield)",
+          iterable: (function* () {
+            yield "the-value";
+          })(),
+        },
+        {
+          type: "Generator (return)",
+          iterable: (function* () {
+            return "the-value";
+          })(),
+        },
+        {
+          type: "Generator (yield*)",
+          iterable: (function* () {
+            // prettier-ignore
+            yield * ["the-value"];
+          })(),
+        },
+        {
+          type: "AsyncGenerator",
+          iterable: (async function* () {
+            await Promise.resolve();
+            yield "the-value";
+          })(),
+        },
+        {
+          type: "ReadableStream (push-mode)",
+          iterable: new ReadableStream({
+            start(controller) {
+              controller.enqueue("the-value");
+              controller.close();
+            },
+          }),
+        },
+        {
+          type: "ReadableStream (pull-mode)",
+          iterable: new ReadableStream({
+            pull(controller) {
+              controller.enqueue("the-value");
+              controller.close();
+            },
+          }),
+        },
+      ])("$type", async ({ iterable }) => {
+        app.use(eventHandler((event) => sendIterable(event, iterable)));
+        const response = await request.get("/");
+        expect(response.text).toBe("the-value");
+      });
+    });
+
+    describe("serializer argument", () => {
+      it("is called for every value", async () => {
+        const iterable = [1, "2", { field: 3 }, null];
+        const serializer = vi.fn(() => "x");
+
+        app.use(
+          eventHandler((event) =>
+            sendIterable(event, iterable, { serializer }),
+          ),
+        );
+        const response = await request.get("/");
+        expect(response.text).toBe("x".repeat(iterable.length));
+        expect(serializer).toBeCalledTimes(4);
+        for (const [i, obj] of iterable.entries()) {
+          expect.soft(serializer).toHaveBeenNthCalledWith(i + 1, obj);
+        }
+      });
+    });
+  });
+});
diff --git a/test/utils.test.ts b/test/utils.test.ts
index 5eb6140..e4511ef 100644
--- a/test/utils.test.ts
+++ b/test/utils.test.ts
@@ -1,5 +1,6 @@
+import { ReadableStream } from "node:stream/web";
 import supertest, { SuperTest, Test } from "supertest";
-import { describe, it, expect, beforeEach } from "vitest";
+import { describe, it, expect, beforeEach, vi } from "vitest";
 import {
   createApp,
   App,
@@ -13,7 +14,9 @@ import {
   readFormData,
   getRequestIP,
   getRequestFingerprint,
+  sendIterable,
 } from "../src";
+import { serializeIterableValue } from "../src/utils/internal/iteratable";
 
 describe("", () => {
   let app: App;
@@ -36,6 +39,138 @@ describe("", () => {
     });
   });
 
+  describe("serializeIterableValue", () => {
+    const exampleDate: Date = new Date(Date.UTC(2015, 6, 21, 3, 24, 54, 888));
+    it.each([
+      { value: "Hello, world!", output: "Hello, world!" },
+      { value: 123, output: "123" },
+      { value: 1n, output: "1" },
+      { value: true, output: "true" },
+      { value: false, output: "false" },
+      { value: undefined, output: undefined },
+      { value: null, output: "null" },
+      { value: exampleDate, output: JSON.stringify(exampleDate) },
+      { value: { field: 1 }, output: '{"field":1}' },
+      { value: [1, 2, 3], output: "[1,2,3]" },
+      { value: () => {}, output: undefined },
+      {
+        value: Buffer.from("Hello, world!"),
+        output: Buffer.from("Hello, world!"),
+      },
+      { value: Uint8Array.from([1, 2, 3]), output: Uint8Array.from([1, 2, 3]) },
+    ])("$value => $output", ({ value, output }) => {
+      const serialized = serializeIterableValue(value);
+      expect(serialized).toStrictEqual(output);
+    });
+  });
+
+  describe("sendIterable", () => {
+    it("sends empty body for an empty iterator", async () => {
+      app.use(eventHandler((event) => sendIterable(event, [])));
+      const result = await request.get("/");
+      expect(result.header["content-length"]).toBe("0");
+      expect(result.text).toBe("");
+    });
+
+    it("concatenates iterated values", async () => {
+      app.use(eventHandler((event) => sendIterable(event, ["a", "b", "c"])));
+      const result = await request.get("/");
+      expect(result.text).toBe("abc");
+    });
+
+    describe("iterable support", () => {
+      it.each([
+        { type: "Array", iterable: ["the-value"] },
+        { type: "Set", iterable: new Set(["the-value"]) },
+        {
+          type: "Map.keys()",
+          iterable: new Map([["the-value", "unused"]]).keys(),
+        },
+        {
+          type: "Map.values()",
+          iterable: new Map([["unused", "the-value"]]).values(),
+        },
+        {
+          type: "Iterator object",
+          iterable: { next: () => ({ value: "the-value", done: true }) },
+        },
+        {
+          type: "AsyncIterator object",
+          iterable: {
+            next: () => Promise.resolve({ value: "the-value", done: true }),
+          },
+        },
+        {
+          type: "Generator (yield)",
+          iterable: (function* () {
+            yield "the-value";
+          })(),
+        },
+        {
+          type: "Generator (return)",
+          iterable: (function* () {
+            return "the-value";
+          })(),
+        },
+        {
+          type: "Generator (yield*)",
+          iterable: (function* () {
+            // prettier-ignore
+            yield * ["the-value"];
+          })(),
+        },
+        {
+          type: "AsyncGenerator",
+          iterable: (async function* () {
+            await Promise.resolve();
+            yield "the-value";
+          })(),
+        },
+        {
+          type: "ReadableStream (push-mode)",
+          iterable: new ReadableStream({
+            start(controller) {
+              controller.enqueue("the-value");
+              controller.close();
+            },
+          }),
+        },
+        {
+          type: "ReadableStream (pull-mode)",
+          iterable: new ReadableStream({
+            pull(controller) {
+              controller.enqueue("the-value");
+              controller.close();
+            },
+          }),
+        },
+      ])("$type", async ({ iterable }) => {
+        app.use(eventHandler((event) => sendIterable(event, iterable)));
+        const response = await request.get("/");
+        expect(response.text).toBe("the-value");
+      });
+    });
+
+    describe("serializer argument", () => {
+      it("is called for every value", async () => {
+        const iterable = [1, "2", { field: 3 }, null];
+        const serializer = vi.fn(() => "x");
+
+        app.use(
+          eventHandler((event) =>
+            sendIterable(event, iterable, { serializer }),
+          ),
+        );
+        const response = await request.get("/");
+        expect(response.text).toBe("x".repeat(iterable.length));
+        expect(serializer).toBeCalledTimes(4);
+        for (const [i, obj] of iterable.entries()) {
+          expect.soft(serializer).toHaveBeenNthCalledWith(i + 1, obj);
+        }
+      });
+    });
+  });
+
   describe("useBase", () => {
     it("can prefix routes", async () => {
       app.use(