Skip to content

Commit 7cd258f

Browse files
authored
feat(util-stream): splitStream and headStream utilities (#1336)
* feat(util-stream): add stream splitting function * variable naming * DRY isReadableStream typeguard * update type guard * rename type guard file * lint
1 parent ae8bf5c commit 7cd258f

11 files changed

+300
-7
lines changed

.changeset/metal-snakes-remember.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@smithy/util-stream": minor
3+
---
4+
5+
add splitStream and headStream utilities

packages/util-stream/package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,19 @@
5656
],
5757
"browser": {
5858
"./dist-es/getAwsChunkedEncodingStream": "./dist-es/getAwsChunkedEncodingStream.browser",
59-
"./dist-es/sdk-stream-mixin": "./dist-es/sdk-stream-mixin.browser"
59+
"./dist-es/headStream": "./dist-es/headStream.browser",
60+
"./dist-es/sdk-stream-mixin": "./dist-es/sdk-stream-mixin.browser",
61+
"./dist-es/splitStream": "./dist-es/splitStream.browser"
6062
},
6163
"react-native": {
6264
"./dist-es/getAwsChunkedEncodingStream": "./dist-es/getAwsChunkedEncodingStream.browser",
6365
"./dist-es/sdk-stream-mixin": "./dist-es/sdk-stream-mixin.browser",
66+
"./dist-es/headStream": "./dist-es/headStream.browser",
67+
"./dist-es/splitStream": "./dist-es/splitStream.browser",
6468
"./dist-cjs/getAwsChunkedEncodingStream": "./dist-cjs/getAwsChunkedEncodingStream.browser",
65-
"./dist-cjs/sdk-stream-mixin": "./dist-cjs/sdk-stream-mixin.browser"
69+
"./dist-cjs/sdk-stream-mixin": "./dist-cjs/sdk-stream-mixin.browser",
70+
"./dist-cjs/headStream": "./dist-cjs/headStream.browser",
71+
"./dist-cjs/splitStream": "./dist-cjs/splitStream.browser"
6672
},
6773
"homepage": "https://github.com/awslabs/smithy-typescript/tree/main/packages/util-stream",
6874
"repository": {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @internal
3+
* @param stream
4+
* @param bytes - read head bytes from the stream and discard the rest of it.
5+
*
6+
* Caution: the input stream must be destroyed separately, this function does not do so.
7+
*/
8+
export async function headStream(stream: ReadableStream, bytes: number): Promise<Uint8Array> {
9+
let byteLengthCounter = 0;
10+
const chunks = [];
11+
const reader = stream.getReader();
12+
let isDone = false;
13+
14+
while (!isDone) {
15+
const { done, value } = await reader.read();
16+
if (value) {
17+
chunks.push(value);
18+
byteLengthCounter += value?.byteLength ?? 0;
19+
}
20+
if (byteLengthCounter >= bytes) {
21+
break;
22+
}
23+
isDone = done;
24+
}
25+
reader.releaseLock();
26+
27+
const collected = new Uint8Array(Math.min(bytes, byteLengthCounter));
28+
let offset = 0;
29+
for (const chunk of chunks) {
30+
if (chunk.byteLength > collected.byteLength - offset) {
31+
collected.set(chunk.subarray(0, collected.byteLength - offset), offset);
32+
break;
33+
} else {
34+
collected.set(chunk, offset);
35+
}
36+
offset += chunk.length;
37+
}
38+
return collected;
39+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Readable } from "stream";
2+
3+
import { headStream } from "./headStream";
4+
import { headStream as headWebStream } from "./headStream.browser";
5+
import { splitStream } from "./splitStream";
6+
import { splitStream as splitWebStream } from "./splitStream.browser";
7+
8+
const CHUNK_SIZE = 4;
9+
const a32 = "abcd".repeat(32_000 / CHUNK_SIZE);
10+
const a16 = "abcd".repeat(16_000 / CHUNK_SIZE);
11+
const a8 = "abcd".repeat(8);
12+
const a4 = "abcd".repeat(4);
13+
const a2 = "abcd".repeat(2);
14+
const a1 = "abcd".repeat(1);
15+
16+
describe(headStream.name, () => {
17+
it("should collect the head of a Node.js stream", async () => {
18+
const data = Buffer.from(a32);
19+
const myStream = Readable.from(data);
20+
21+
const head = await headStream(myStream, 16_000);
22+
23+
expect(Buffer.from(head).toString()).toEqual(a16);
24+
});
25+
26+
it("should collect the head of a web stream", async () => {
27+
if (typeof ReadableStream !== "undefined") {
28+
const buffer = Buffer.from(a32);
29+
const data = Array.from(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
30+
31+
const myStream = new ReadableStream({
32+
start(controller) {
33+
for (const inputChunk of data) {
34+
controller.enqueue(new Uint8Array([inputChunk]));
35+
}
36+
controller.close();
37+
},
38+
});
39+
40+
const head = await headWebStream(myStream, 16_000);
41+
expect(Buffer.from(head).toString()).toEqual(a16);
42+
}
43+
});
44+
});
45+
46+
describe("splitStream and headStream integration", () => {
47+
it("should split and head streams for Node.js", async () => {
48+
const data = Buffer.from(a32);
49+
const myStream = Readable.from(data);
50+
51+
const [a, _1] = await splitStream(myStream);
52+
const [b, _2] = await splitStream(_1);
53+
const [c, _3] = await splitStream(_2);
54+
const [d, _4] = await splitStream(_3);
55+
const [e, f] = await splitStream(_4);
56+
57+
const byteArr1 = await headStream(a, Infinity);
58+
const byteArr2 = await headStream(b, 16_000);
59+
const byteArr3 = await headStream(c, 8 * CHUNK_SIZE);
60+
const byteArr4 = await headStream(d, 4 * CHUNK_SIZE);
61+
const byteArr5 = await headStream(e, 2 * CHUNK_SIZE);
62+
const byteArr6 = await headStream(f, CHUNK_SIZE);
63+
64+
await Promise.all([a, b, c, d, e, f].map((stream) => stream.destroy()));
65+
66+
expect(Buffer.from(byteArr1).toString()).toEqual(a32);
67+
expect(Buffer.from(byteArr2).toString()).toEqual(a16);
68+
expect(Buffer.from(byteArr3).toString()).toEqual(a8);
69+
expect(Buffer.from(byteArr4).toString()).toEqual(a4);
70+
expect(Buffer.from(byteArr5).toString()).toEqual(a2);
71+
expect(Buffer.from(byteArr6).toString()).toEqual(a1);
72+
});
73+
74+
it("should split and head streams for web streams API", async () => {
75+
if (typeof ReadableStream !== "undefined") {
76+
const buffer = Buffer.from(a8);
77+
const data = Array.from(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
78+
79+
const myStream = new ReadableStream({
80+
start(controller) {
81+
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
82+
controller.enqueue(new Uint8Array(data.slice(i, i + CHUNK_SIZE)));
83+
}
84+
controller.close();
85+
},
86+
});
87+
88+
const [a, _1] = await splitWebStream(myStream);
89+
const [b, _2] = await splitWebStream(_1);
90+
const [c, _3] = await splitWebStream(_2);
91+
const [d, e] = await splitWebStream(_3);
92+
93+
const byteArr1 = await headWebStream(a, Infinity);
94+
const byteArr2 = await headWebStream(b, 8 * CHUNK_SIZE);
95+
const byteArr3 = await headWebStream(c, 4 * CHUNK_SIZE);
96+
const byteArr4 = await headWebStream(d, 2 * CHUNK_SIZE);
97+
const byteArr5 = await headWebStream(e, CHUNK_SIZE);
98+
99+
await Promise.all([a, b, c, d, e].map((stream) => stream.cancel()));
100+
101+
expect(Buffer.from(byteArr1).toString()).toEqual(a8);
102+
expect(Buffer.from(byteArr2).toString()).toEqual(a8);
103+
expect(Buffer.from(byteArr3).toString()).toEqual(a4);
104+
expect(Buffer.from(byteArr4).toString()).toEqual(a2);
105+
expect(Buffer.from(byteArr5).toString()).toEqual(a1);
106+
}
107+
});
108+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Readable, Writable } from "stream";
2+
3+
import { headStream as headWebStream } from "./headStream.browser";
4+
import { isReadableStream } from "./stream-type-check";
5+
6+
/**
7+
* @internal
8+
* @param stream
9+
* @param bytes - read head bytes from the stream and discard the rest of it.
10+
*
11+
* Caution: the input stream must be destroyed separately, this function does not do so.
12+
*/
13+
export const headStream = (stream: Readable | ReadableStream, bytes: number): Promise<Uint8Array> => {
14+
if (isReadableStream(stream)) {
15+
return headWebStream(stream, bytes);
16+
}
17+
return new Promise((resolve, reject) => {
18+
const collector = new Collector();
19+
collector.limit = bytes;
20+
stream.pipe(collector);
21+
stream.on("error", (err) => {
22+
collector.end();
23+
reject(err);
24+
});
25+
collector.on("error", reject);
26+
collector.on("finish", function (this: Collector) {
27+
const bytes = new Uint8Array(Buffer.concat(this.buffers));
28+
resolve(bytes);
29+
});
30+
});
31+
};
32+
33+
class Collector extends Writable {
34+
public readonly buffers: Buffer[] = [];
35+
public limit = Infinity;
36+
private bytesBuffered = 0;
37+
38+
_write(chunk: Buffer, encoding: string, callback: (err?: Error) => void) {
39+
this.buffers.push(chunk);
40+
this.bytesBuffered += chunk.byteLength ?? 0;
41+
if (this.bytesBuffered >= this.limit) {
42+
const excess = this.bytesBuffered - this.limit;
43+
const tailBuffer = this.buffers[this.buffers.length - 1];
44+
this.buffers[this.buffers.length - 1] = tailBuffer.subarray(0, tailBuffer.byteLength - excess);
45+
this.emit("finish");
46+
}
47+
callback();
48+
}
49+
}

packages/util-stream/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export * from "./blob/Uint8ArrayBlobAdapter";
22
export * from "./getAwsChunkedEncodingStream";
33
export * from "./sdk-stream-mixin";
4+
export * from "./splitStream";
5+
export * from "./headStream";
6+
export * from "./stream-type-check";

packages/util-stream/src/sdk-stream-mixin.browser.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { toBase64 } from "@smithy/util-base64";
44
import { toHex } from "@smithy/util-hex-encoding";
55
import { toUtf8 } from "@smithy/util-utf8";
66

7+
import { isReadableStream } from "./stream-type-check";
8+
79
const ERR_MSG_STREAM_HAS_BEEN_TRANSFORMED = "The stream has already been transformed.";
810

911
/**
@@ -12,7 +14,7 @@ const ERR_MSG_STREAM_HAS_BEEN_TRANSFORMED = "The stream has already been transfo
1214
* @internal
1315
*/
1416
export const sdkStreamMixin = (stream: unknown): SdkStream<ReadableStream | Blob> => {
15-
if (!isBlobInstance(stream) && !isReadableStreamInstance(stream)) {
17+
if (!isBlobInstance(stream) && !isReadableStream(stream)) {
1618
//@ts-ignore
1719
const name = stream?.__proto__?.constructor?.name || stream;
1820
throw new Error(`Unexpected stream implementation, expect Blob or ReadableStream, got ${name}`);
@@ -64,7 +66,7 @@ export const sdkStreamMixin = (stream: unknown): SdkStream<ReadableStream | Blob
6466
if (isBlobInstance(stream)) {
6567
// ReadableStream is undefined in React Native
6668
return blobToWebStream(stream);
67-
} else if (isReadableStreamInstance(stream)) {
69+
} else if (isReadableStream(stream)) {
6870
return stream;
6971
} else {
7072
throw new Error(`Cannot transform payload to web stream, got ${stream}`);
@@ -74,6 +76,3 @@ export const sdkStreamMixin = (stream: unknown): SdkStream<ReadableStream | Blob
7476
};
7577

7678
const isBlobInstance = (stream: unknown): stream is Blob => typeof Blob === "function" && stream instanceof Blob;
77-
78-
const isReadableStreamInstance = (stream: unknown): stream is ReadableStream =>
79-
typeof ReadableStream === "function" && stream instanceof ReadableStream;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* @param stream
3+
* @returns stream split into two identical streams.
4+
*/
5+
export async function splitStream(stream: ReadableStream | Blob): Promise<[ReadableStream, ReadableStream]> {
6+
if (typeof (stream as Blob).stream === "function") {
7+
stream = (stream as Blob).stream();
8+
}
9+
const readableStream = stream as ReadableStream;
10+
return readableStream.tee();
11+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { streamCollector as webStreamCollector } from "@smithy/fetch-http-handler";
2+
import { streamCollector } from "@smithy/node-http-handler";
3+
import { Readable } from "stream";
4+
5+
import { splitStream } from "./splitStream";
6+
import { splitStream as splitWebStream } from "./splitStream.browser";
7+
8+
describe(splitStream.name, () => {
9+
it("should split a node:Readable stream", async () => {
10+
const data = Buffer.from("abcd");
11+
12+
const myStream = Readable.from(data);
13+
const [a, b] = await splitStream(myStream);
14+
15+
const buffer1 = await streamCollector(a);
16+
const buffer2 = await streamCollector(b);
17+
18+
expect(buffer1).toEqual(new Uint8Array([97, 98, 99, 100]));
19+
expect(buffer1).toEqual(buffer2);
20+
});
21+
it("should split a web:ReadableStream stream", async () => {
22+
if (typeof ReadableStream !== "undefined") {
23+
const inputChunks = [97, 98, 99, 100];
24+
25+
const myStream = new ReadableStream({
26+
start(controller) {
27+
for (const inputChunk of inputChunks) {
28+
controller.enqueue(new Uint8Array([inputChunk]));
29+
}
30+
controller.close();
31+
},
32+
});
33+
34+
const [a, b] = await splitWebStream(myStream);
35+
36+
const bytes1 = await webStreamCollector(a);
37+
const bytes2 = await webStreamCollector(b);
38+
39+
expect(bytes1).toEqual(new Uint8Array([97, 98, 99, 100]));
40+
expect(bytes1).toEqual(bytes2);
41+
}
42+
});
43+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Readable } from "stream";
2+
import { PassThrough } from "stream";
3+
4+
import { splitStream as splitWebStream } from "./splitStream.browser";
5+
import { isReadableStream } from "./stream-type-check";
6+
7+
/**
8+
* @param stream
9+
* @returns stream split into two identical streams.
10+
*/
11+
export async function splitStream(stream: Readable): Promise<[Readable, Readable]>;
12+
export async function splitStream(stream: ReadableStream): Promise<[ReadableStream, ReadableStream]>;
13+
export async function splitStream(
14+
stream: Readable | ReadableStream
15+
): Promise<[Readable | ReadableStream, Readable | ReadableStream]> {
16+
if (isReadableStream(stream)) {
17+
return splitWebStream(stream);
18+
}
19+
const stream1 = new PassThrough();
20+
const stream2 = new PassThrough();
21+
stream.pipe(stream1);
22+
stream.pipe(stream2);
23+
return [stream1, stream2];
24+
}

0 commit comments

Comments
 (0)