diff --git a/src/bun.js/bindings/webcore/JSReadableStream.cpp b/src/bun.js/bindings/webcore/JSReadableStream.cpp index 1b5c4fc84d..e9d4cb7990 100644 --- a/src/bun.js/bindings/webcore/JSReadableStream.cpp +++ b/src/bun.js/bindings/webcore/JSReadableStream.cpp @@ -153,6 +153,7 @@ template<> void JSReadableStreamDOMConstructor::initializeProperties(VM& vm, JSD m_originalName.set(vm, this, nameString); putDirect(vm, vm.propertyNames->name, nameString, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); putDirect(vm, vm.propertyNames->prototype, JSReadableStream::prototype(vm, globalObject), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete); + putDirectBuiltinFunction(vm, &globalObject, Identifier::fromString(vm, "from"_s), readableStreamFromCodeGenerator(vm), static_cast(JSC::PropertyAttribute::DontEnum)); } template<> FunctionExecutable* JSReadableStreamDOMConstructor::initializeExecutable(VM& vm) @@ -279,6 +280,7 @@ void JSReadableStream::destroy(JSC::JSCell* cell) thisObject->JSReadableStream::~JSReadableStream(); } + JSC_DEFINE_CUSTOM_GETTER(jsReadableStreamConstructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) { auto& vm = JSC::getVM(lexicalGlobalObject); diff --git a/src/js/builtins/ReadableStream.ts b/src/js/builtins/ReadableStream.ts index 4aac6d69aa..30692aac50 100644 --- a/src/js/builtins/ReadableStream.ts +++ b/src/js/builtins/ReadableStream.ts @@ -513,3 +513,103 @@ export function lazyAsyncIterator(this) { $readableStreamDefineLazyIterators(prototype); return prototype[globalThis.Symbol.asyncIterator].$call(this); } + +$linkTimeConstant; +export function from(asyncIterable) { + if (asyncIterable == null) { + throw new TypeError("ReadableStream.from() takes a non-null value"); + } + + // Check if it's already a ReadableStream + if ($isReadableStream(asyncIterable)) { + return asyncIterable; + } + + // Handle arrays with Array.fromAsync + if ($isArray(asyncIterable)) { + return new ReadableStream({ + async start(controller) { + try { + for (let i = 0; i < asyncIterable.length; i++) { + controller.enqueue(asyncIterable[i]); + } + controller.close(); + } catch (error) { + controller.error(error); + } + } + }); + } + + // Handle iterables (sync and async) + let asyncIteratorMethod = asyncIterable[globalThis.Symbol.asyncIterator]; + let iteratorMethod = asyncIterable[globalThis.Symbol.iterator]; + + if (asyncIteratorMethod != null) { + // Async iterable + if (typeof asyncIteratorMethod !== "function") { + throw new TypeError("ReadableStream.from() argument's @@asyncIterator method must be a function"); + } + + return new ReadableStream({ + async start(controller) { + try { + const iterator = asyncIteratorMethod.$call(asyncIterable); + if (!$isObject(iterator)) { + throw new TypeError("ReadableStream.from() argument's @@asyncIterator method must return an object"); + } + + while (true) { + const result = await iterator.next(); + if (!$isObject(result)) { + throw new TypeError("Iterator result must be an object"); + } + + if (result.done) { + controller.close(); + break; + } + + controller.enqueue(result.value); + } + } catch (error) { + controller.error(error); + } + } + }); + } else if (iteratorMethod != null) { + // Sync iterable + if (typeof iteratorMethod !== "function") { + throw new TypeError("ReadableStream.from() argument's @@iterator method must be a function"); + } + + return new ReadableStream({ + start(controller) { + try { + const iterator = iteratorMethod.$call(asyncIterable); + if (!$isObject(iterator)) { + throw new TypeError("ReadableStream.from() argument's @@iterator method must return an object"); + } + + while (true) { + const result = iterator.next(); + if (!$isObject(result)) { + throw new TypeError("Iterator result must be an object"); + } + + if (result.done) { + controller.close(); + break; + } + + controller.enqueue(result.value); + } + } catch (error) { + controller.error(error); + } + } + }); + } else { + throw new TypeError("ReadableStream.from() argument must be an iterable or async iterable"); + } +} diff --git a/test/js/web/streams/readable-stream-from.test.ts b/test/js/web/streams/readable-stream-from.test.ts new file mode 100644 index 0000000000..f26a62e0db --- /dev/null +++ b/test/js/web/streams/readable-stream-from.test.ts @@ -0,0 +1,280 @@ +import { test, expect } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; + +test("ReadableStream.from", () => { + expect(typeof ReadableStream.from).toBe("function"); + expect(ReadableStream.from.length).toBe(1); +}); + +test("ReadableStream.from() with array", async () => { + const array = [1, 2, 3, 4, 5]; + const stream = ReadableStream.from(array); + + expect(stream).toBeInstanceOf(ReadableStream); + + const reader = stream.getReader(); + const results: number[] = []; + + let done = false; + while (!done) { + const { value, done: isDone } = await reader.read(); + done = isDone; + if (!done) { + results.push(value); + } + } + + expect(results).toEqual([1, 2, 3, 4, 5]); +}); + +test("ReadableStream.from() with empty array", async () => { + const array: number[] = []; + const stream = ReadableStream.from(array); + + const reader = stream.getReader(); + const { value, done } = await reader.read(); + + expect(done).toBe(true); + expect(value).toBeUndefined(); +}); + +test("ReadableStream.from() with string (iterable)", async () => { + const str = "hello"; + const stream = ReadableStream.from(str); + + const reader = stream.getReader(); + const results: string[] = []; + + let done = false; + while (!done) { + const { value, done: isDone } = await reader.read(); + done = isDone; + if (!done) { + results.push(value); + } + } + + expect(results).toEqual(["h", "e", "l", "l", "o"]); +}); + +test("ReadableStream.from() with Set", async () => { + const set = new Set([1, 2, 3]); + const stream = ReadableStream.from(set); + + const reader = stream.getReader(); + const results: number[] = []; + + let done = false; + while (!done) { + const { value, done: isDone } = await reader.read(); + done = isDone; + if (!done) { + results.push(value); + } + } + + expect(results).toEqual([1, 2, 3]); +}); + +test("ReadableStream.from() with Map", async () => { + const map = new Map([["a", 1], ["b", 2], ["c", 3]]); + const stream = ReadableStream.from(map); + + const reader = stream.getReader(); + const results: [string, number][] = []; + + let done = false; + while (!done) { + const { value, done: isDone } = await reader.read(); + done = isDone; + if (!done) { + results.push(value); + } + } + + expect(results).toEqual([["a", 1], ["b", 2], ["c", 3]]); +}); + +test("ReadableStream.from() with custom iterable", async () => { + const customIterable = { + *[Symbol.iterator]() { + yield 1; + yield 2; + yield 3; + } + }; + + const stream = ReadableStream.from(customIterable); + + const reader = stream.getReader(); + const results: number[] = []; + + let done = false; + while (!done) { + const { value, done: isDone } = await reader.read(); + done = isDone; + if (!done) { + results.push(value); + } + } + + expect(results).toEqual([1, 2, 3]); +}); + +test("ReadableStream.from() with async iterable", async () => { + const asyncIterable = { + async *[Symbol.asyncIterator]() { + yield 1; + yield 2; + yield 3; + } + }; + + const stream = ReadableStream.from(asyncIterable); + + const reader = stream.getReader(); + const results: number[] = []; + + let done = false; + while (!done) { + const { value, done: isDone } = await reader.read(); + done = isDone; + if (!done) { + results.push(value); + } + } + + expect(results).toEqual([1, 2, 3]); +}); + +test("ReadableStream.from() with existing ReadableStream", async () => { + const originalStream = new ReadableStream({ + start(controller) { + controller.enqueue(1); + controller.enqueue(2); + controller.enqueue(3); + controller.close(); + } + }); + + const stream = ReadableStream.from(originalStream); + + // Should return the same stream + expect(stream).toBe(originalStream); +}); + +test("ReadableStream.from() with null should throw", () => { + expect(() => ReadableStream.from(null)).toThrow("ReadableStream.from() takes a non-null value"); +}); + +test("ReadableStream.from() with undefined should throw", () => { + expect(() => ReadableStream.from(undefined)).toThrow("ReadableStream.from() takes a non-null value"); +}); + +test("ReadableStream.from() with non-iterable should throw", () => { + const nonIterable = {}; + expect(() => ReadableStream.from(nonIterable)).toThrow("ReadableStream.from() argument must be an iterable or async iterable"); +}); + +test("ReadableStream.from() with invalid iterator method should throw", () => { + const invalidIterable = { + [Symbol.iterator]: "not a function" + }; + + expect(() => ReadableStream.from(invalidIterable)).toThrow("ReadableStream.from() argument's @@iterator method must be a function"); +}); + +test("ReadableStream.from() with invalid async iterator method should throw", () => { + const invalidAsyncIterable = { + [Symbol.asyncIterator]: "not a function" + }; + + expect(() => ReadableStream.from(invalidAsyncIterable)).toThrow("ReadableStream.from() argument's @@asyncIterator method must be a function"); +}); + +test("ReadableStream.from() handles iterator that throws", async () => { + const throwingIterable = { + *[Symbol.iterator]() { + yield 1; + throw new Error("Iterator error"); + } + }; + + const stream = ReadableStream.from(throwingIterable); + const reader = stream.getReader(); + + // Should read first value successfully + const { value } = await reader.read(); + expect(value).toBe(1); + + // Should handle error from iterator + await expect(reader.read()).rejects.toThrow("Iterator error"); +}); + +test("ReadableStream.from() handles async iterator that throws", async () => { + const throwingAsyncIterable = { + async *[Symbol.asyncIterator]() { + yield 1; + throw new Error("Async iterator error"); + } + }; + + const stream = ReadableStream.from(throwingAsyncIterable); + const reader = stream.getReader(); + + // Should read first value successfully + const { value } = await reader.read(); + expect(value).toBe(1); + + // Should handle error from async iterator + await expect(reader.read()).rejects.toThrow("Async iterator error"); +}); + +test("ReadableStream.from() works with Array.from() like usage", async () => { + // Test that it works similar to how Array.from() works + const stream1 = ReadableStream.from("abc"); + const stream2 = ReadableStream.from([1, 2, 3]); + const stream3 = ReadableStream.from(new Set(["x", "y", "z"])); + + const result1 = await new Response(stream1).text(); + const result2 = await streamToArray(stream2); + const result3 = await streamToArray(stream3); + + // For string, each character should be a separate chunk + expect(result1).toBe("abc"); + expect(result2).toEqual([1, 2, 3]); + expect(result3).toEqual(["x", "y", "z"]); +}); + +// Helper function to convert stream to array +async function streamToArray(stream: ReadableStream) { + const reader = stream.getReader(); + const results: any[] = []; + + let done = false; + while (!done) { + const { value, done: isDone } = await reader.read(); + done = isDone; + if (!done) { + results.push(value); + } + } + + return results; +} + +test("ReadableStream.from() as static method", () => { + // Test that it's actually a static method on the constructor + expect(ReadableStream.hasOwnProperty("from")).toBe(true); + expect(ReadableStream.from).toBe(ReadableStream.from); + expect(typeof ReadableStream.from).toBe("function"); +}); + +test("ReadableStream.from() integration with Response", async () => { + // Test integration with other Web APIs that consume ReadableStream + const stream = ReadableStream.from(["hello", " ", "world"]); + const response = new Response(stream); + const text = await response.text(); + + expect(text).toBe("hello world"); +}); \ No newline at end of file