Skip to content

Commit 0ef4af6

Browse files
authored
feat(util-stream-node): provide handling utilities for Node.js stream (#3778)
* feat(util-sdk-stream): implement sdk stream utility mixin * feat: types of the SDK Stream feat(util-stream-node): merge util-sdk-stream into util-stream-node(browser) feeat(util-stream-node): rename transformToBuffer to transformToByteArray; unit test * feat(util-stream-node): update sdkStreamMixin input to unknown
1 parent 5b2dc89 commit 0ef4af6

File tree

4 files changed

+223
-0
lines changed

4 files changed

+223
-0
lines changed

packages/util-stream-node/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
},
2020
"license": "Apache-2.0",
2121
"dependencies": {
22+
"@aws-sdk/node-http-handler": "*",
2223
"@aws-sdk/types": "*",
24+
"@aws-sdk/util-buffer-from": "*",
2325
"tslib": "^2.3.1"
2426
},
2527
"devDependencies": {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from "./getAwsChunkedEncodingStream";
2+
export * from "./sdk-stream-mixin";
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { SdkStreamMixin } from "@aws-sdk/types";
2+
import { fromArrayBuffer } from "@aws-sdk/util-buffer-from";
3+
import { PassThrough, Readable, Writable } from "stream";
4+
5+
import { sdkStreamMixin } from "./sdk-stream-mixin";
6+
7+
jest.mock("@aws-sdk/util-buffer-from");
8+
9+
describe(sdkStreamMixin.name, () => {
10+
const writeDataToStream = (stream: Writable, data: Array<ArrayBufferLike>): Promise<void> =>
11+
new Promise((resolve, reject) => {
12+
data.forEach((chunk) => {
13+
stream.write(chunk, (err) => {
14+
if (err) reject(err);
15+
});
16+
});
17+
stream.end(resolve);
18+
});
19+
const byteArrayFromBuffer = (buf: Buffer) => new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
20+
let passThrough: PassThrough;
21+
const expectAllTransformsToFail = async (sdkStream: SdkStreamMixin) => {
22+
const transformMethods: Array<keyof SdkStreamMixin> = [
23+
"transformToByteArray",
24+
"transformToString",
25+
"transformToWebStream",
26+
];
27+
for (const method of transformMethods) {
28+
try {
29+
await sdkStream[method]();
30+
fail(new Error("expect subsequent tranform to fail"));
31+
} catch (error) {
32+
expect(error.message).toContain("The stream has already been transformed");
33+
}
34+
}
35+
};
36+
37+
beforeEach(() => {
38+
passThrough = new PassThrough();
39+
});
40+
41+
it("should throw if unexpected stream implementation is supplied", () => {
42+
try {
43+
const payload = {};
44+
sdkStreamMixin(payload);
45+
fail("should throw when unexpected stream is supplied");
46+
} catch (error) {
47+
expect(error.message).toContain("Unexpected stream implementation");
48+
}
49+
});
50+
51+
describe("transformToByteArray", () => {
52+
it("should transform binary stream to byte array", async () => {
53+
const mockData = [Buffer.from("foo"), Buffer.from("bar"), Buffer.from("buzz")];
54+
const expected = byteArrayFromBuffer(Buffer.from("foobarbuzz"));
55+
const sdkStream = sdkStreamMixin(passThrough);
56+
await writeDataToStream(passThrough, mockData);
57+
expect(await sdkStream.transformToByteArray()).toEqual(expected);
58+
});
59+
60+
it("should fail any subsequent tranform calls", async () => {
61+
const sdkStream = sdkStreamMixin(passThrough);
62+
await writeDataToStream(passThrough, [Buffer.from("abc")]);
63+
expect(await sdkStream.transformToByteArray()).toEqual(byteArrayFromBuffer(Buffer.from("abc")));
64+
await expectAllTransformsToFail(sdkStream);
65+
});
66+
});
67+
68+
describe("transformToString", () => {
69+
const toStringMock = jest.fn();
70+
beforeAll(() => {
71+
jest.resetAllMocks();
72+
});
73+
74+
it("should transform the stream to string with utf-8 encoding by default", async () => {
75+
(fromArrayBuffer as jest.Mock).mockImplementation(
76+
jest.requireActual("@aws-sdk/util-buffer-from").fromArrayBuffer
77+
);
78+
const sdkStream = sdkStreamMixin(passThrough);
79+
await writeDataToStream(passThrough, [Buffer.from("foo")]);
80+
const transformed = await sdkStream.transformToString();
81+
expect(transformed).toEqual("foo");
82+
});
83+
84+
it.each([undefined, "utf-8", "ascii", "base64", "latin1", "binary"])(
85+
"should transform the stream to string with %s encoding",
86+
async (encoding) => {
87+
(fromArrayBuffer as jest.Mock).mockReturnValue({ toString: toStringMock });
88+
const sdkStream = sdkStreamMixin(passThrough);
89+
await writeDataToStream(passThrough, [Buffer.from("foo")]);
90+
await sdkStream.transformToString(encoding);
91+
expect(toStringMock).toBeCalledWith(encoding);
92+
}
93+
);
94+
95+
it("should fail any subsequent tranform calls", async () => {
96+
const sdkStream = sdkStreamMixin(passThrough);
97+
await writeDataToStream(passThrough, [Buffer.from("foo")]);
98+
await sdkStream.transformToString();
99+
await expectAllTransformsToFail(sdkStream);
100+
});
101+
});
102+
103+
describe("transformToWebStream", () => {
104+
it("should throw if any event listener is attached on the underlying stream", async () => {
105+
passThrough.on("data", console.log);
106+
const sdkStream = sdkStreamMixin(passThrough);
107+
try {
108+
sdkStream.transformToWebStream();
109+
fail(new Error("expect web stream transformation to fail"));
110+
} catch (error) {
111+
expect(error.message).toContain("The stream has been consumed by other callbacks");
112+
}
113+
});
114+
115+
describe("when Readable.toWeb() is not supported", () => {
116+
// @ts-expect-error
117+
const originalToWebImpl = Readable.toWeb;
118+
beforeAll(() => {
119+
// @ts-expect-error
120+
Readable.toWeb = undefined;
121+
});
122+
afterAll(() => {
123+
// @ts-expect-error
124+
Readable.toWeb = originalToWebImpl;
125+
});
126+
127+
it("should throw", async () => {
128+
const sdkStream = sdkStreamMixin(passThrough);
129+
try {
130+
sdkStream.transformToWebStream();
131+
fail(new Error("expect web stream transformation to fail"));
132+
} catch (error) {
133+
expect(error.message).toContain("Readable.toWeb() is not supported");
134+
}
135+
});
136+
});
137+
138+
describe("when Readable.toWeb() is supported", () => {
139+
// @ts-expect-error
140+
const originalToWebImpl = Readable.toWeb;
141+
beforeAll(() => {
142+
// @ts-expect-error
143+
Readable.toWeb = jest.fn().mockReturnValue("A web stream");
144+
});
145+
146+
afterAll(() => {
147+
// @ts-expect-error
148+
Readable.toWeb = originalToWebImpl;
149+
});
150+
151+
it("should tranform Node stream to web stream", async () => {
152+
const sdkStream = sdkStreamMixin(passThrough);
153+
sdkStream.transformToWebStream();
154+
// @ts-expect-error
155+
expect(Readable.toWeb).toBeCalled();
156+
});
157+
158+
it("should fail any subsequent tranform calls", async () => {
159+
const sdkStream = sdkStreamMixin(passThrough);
160+
await writeDataToStream(passThrough, [Buffer.from("foo")]);
161+
await sdkStream.transformToWebStream();
162+
await expectAllTransformsToFail(sdkStream);
163+
});
164+
});
165+
});
166+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { streamCollector } from "@aws-sdk/node-http-handler";
2+
import { SdkStream, SdkStreamMixin } from "@aws-sdk/types";
3+
import { fromArrayBuffer } from "@aws-sdk/util-buffer-from";
4+
import { Readable } from "stream";
5+
6+
const ERR_MSG_STREAM_HAS_BEEN_TRANSFORMED = "The stream has already been transformed.";
7+
8+
/**
9+
* The function that mixes in the utility functions to help consuming runtime-specific payload stream.
10+
*
11+
* @internal
12+
*/
13+
export const sdkStreamMixin = (stream: unknown): SdkStream<Readable> => {
14+
if (!(stream instanceof Readable)) {
15+
// @ts-ignore
16+
const name = stream?.__proto__?.constructor?.name || stream;
17+
throw new Error(`Unexpected stream implementation, expect Stream.Readable instance, got ${name}`);
18+
}
19+
20+
let transformed = false;
21+
const transformToByteArray = async () => {
22+
if (transformed) {
23+
throw new Error(ERR_MSG_STREAM_HAS_BEEN_TRANSFORMED);
24+
}
25+
transformed = true;
26+
return await streamCollector(stream);
27+
};
28+
29+
return Object.assign<Readable, SdkStreamMixin>(stream, {
30+
transformToByteArray,
31+
transformToString: async (encoding?: string) => {
32+
const buf = await transformToByteArray();
33+
return fromArrayBuffer(buf.buffer, buf.byteOffset, buf.byteLength).toString(encoding);
34+
},
35+
transformToWebStream: () => {
36+
if (transformed) {
37+
throw new Error(ERR_MSG_STREAM_HAS_BEEN_TRANSFORMED);
38+
}
39+
if (stream.readableFlowing !== null) {
40+
// Prevent side effect of consuming webstream.
41+
throw new Error("The stream has been consumed by other callbacks.");
42+
}
43+
// @ts-expect-error toWeb() is only available in Node.js >= 17.0.0
44+
if (typeof Readable.toWeb !== "function") {
45+
throw new Error(
46+
"Readable.toWeb() is not supported. Please make sure you are using Node.js >= 17.0.0, or polyfill is available."
47+
);
48+
}
49+
transformed = true;
50+
// @ts-expect-error toWeb() is only available in Node.js >= 17.0.0
51+
return Readable.toWeb(stream);
52+
},
53+
});
54+
};

0 commit comments

Comments
 (0)