Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/js/builtins.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ declare function $webStreamClosedPromise(stream: ReadableStream | WritableStream
* no-op once the stream is no longer readable/writable. Throws for any other value.
*/
declare function $webStreamControllerError(stream: ReadableStream | WritableStream, error: unknown): void;

/**
* Read a WHATWG ReadableStream/WritableStream's [[state]] internal slot as an integer.
* ReadableStream: 0 = readable, 1 = closed, 2 = errored.
* WritableStream: 0 = writable, 1 = erroring, 2 = errored, 3 = closed.
* Throws for any other value.
*/
declare function $webStreamState(stream: ReadableStream | WritableStream): number;
declare function $getInternalField<Fields extends any[], N extends keyof Fields>(
base: InternalFieldObject<Fields>,
number: N,
Expand Down
1 change: 1 addition & 0 deletions src/js/builtins/BunBuiltinNames.h
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ using namespace JSC;
macro(warning) \
macro(webStreamClosedPromise) \
macro(webStreamControllerError) \
macro(webStreamState) \
macro(writable) \
macro(writableType) \
macro(write) \
Expand Down
11 changes: 10 additions & 1 deletion src/js/internal/streams/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,17 @@ function isReadableFinished(stream, strict?) {

function isReadable(stream) {
if (stream && stream[kIsReadable] != null) return stream[kIsReadable];
// Bun's WHATWG streams are native and don't carry node's kIsReadable brand; read [[state]]
// directly to match node's ReadableStream.prototype[kIsReadable] getter.
if (isReadableStream(stream)) return $webStreamState(stream) === 0; // "readable"
if (typeof stream?.readable !== "boolean") return null;
if (isDestroyed(stream)) return false;
return isReadableNodeStream(stream) && stream.readable && !isReadableFinished(stream);
}

function isWritable(stream) {
if (stream && stream[kIsWritable] != null) return stream[kIsWritable];
if (isWritableStream(stream)) return $webStreamState(stream) === 0; // "writable"
if (typeof stream?.writable !== "boolean") return null;
if (isDestroyed(stream)) return false;
return isWritableNodeStream(stream) && stream.writable && !isWritableEnded(stream);
Expand Down Expand Up @@ -261,13 +265,18 @@ function willEmitClose(stream) {
}

function isDisturbed(stream) {
return !!(stream && (stream[kIsDisturbed] ?? (stream.readableDidRead || stream.readableAborted)));
return !!(
stream &&
(stream[kIsDisturbed] ??
(isReadableStream(stream) ? stream.$disturbed : stream.readableDidRead || stream.readableAborted))
);
}

function isErrored(stream) {
return !!(
stream &&
(stream[kIsErrored] ??
(isReadableStream(stream) || isWritableStream(stream) ? $webStreamState(stream) === 2 : undefined) ??
stream.readableErrored ??
stream.writableErrored ??
stream._readableState?.errorEmitted ??
Expand Down
17 changes: 17 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,22 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamClosedPromise, (JSGlobalObject * globalObjec
return JSValue::encode(throwTypeError(globalObject, scope, "Expected a ReadableStream or WritableStream"_s));
}

// node:stream's isReadable/isWritable/isErrored/isDisturbed expect WHATWG streams to carry the
// Symbol.for("nodejs.stream.*") brand getters that node's own web stream impl defines. Bun's
// native streams don't, so those helpers duck-type the argument and read the underlying
// [[state]] here instead. Callers gate on isReadableStream()/isWritableStream() first.
Comment thread
robobun marked this conversation as resolved.
Outdated
JSC_DEFINE_HOST_FUNCTION(jsWebStreamState, (JSGlobalObject * globalObject, CallFrame* callFrame))
Comment thread
robobun marked this conversation as resolved.
{
JSValue streamValue = callFrame->argument(0);
if (auto* readable = dynamicDowncast<WebCore::JSReadableStream>(streamValue))
return JSValue::encode(jsNumber(static_cast<int32_t>(readable->m_state)));
if (auto* writable = dynamicDowncast<WebCore::JSWritableStream>(streamValue))
return JSValue::encode(jsNumber(static_cast<int32_t>(writable->m_state)));

auto scope = DECLARE_THROW_SCOPE(getVM(globalObject));
return JSValue::encode(throwTypeError(globalObject, scope, "Expected a ReadableStream or WritableStream"_s));
}

// node:stream's addAbortSignal() errors a WHATWG stream when the signal fires. Its isWebStream()
// gate also admits TransformStream, which has no controller to error — node throws there too (it
// never sets kControllerErrorFunction on one), so the throw below is reachable, not dead code.
Expand Down Expand Up @@ -3058,6 +3074,7 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm)
GlobalPropertyInfo(builtinNames.pokePromiseAsHandledPrivateName(), JSFunction::create(vm, this, 1, String(), jsBunPokePromiseAsHandled, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly),
GlobalPropertyInfo(builtinNames.webStreamClosedPromisePrivateName(), JSFunction::create(vm, this, 1, String(), jsWebStreamClosedPromise, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly),
GlobalPropertyInfo(builtinNames.webStreamControllerErrorPrivateName(), JSFunction::create(vm, this, 2, String(), jsWebStreamControllerError, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly),
GlobalPropertyInfo(builtinNames.webStreamStatePrivateName(), JSFunction::create(vm, this, 1, String(), jsWebStreamState, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly),
GlobalPropertyInfo(builtinNames.fulfillModuleSyncPrivateName(), JSFunction::create(vm, this, 1, String(), functionFulfillModuleSync, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly),
GlobalPropertyInfo(builtinNames.esmNamespaceForCjsPrivateName(), JSFunction::create(vm, this, 1, String(), functionEsmNamespaceForCjs, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly),
GlobalPropertyInfo(builtinNames.esmRegistryDeletePrivateName(), JSFunction::create(vm, this, 1, String(), functionEsmRegistryDelete, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly),
Expand Down
134 changes: 133 additions & 1 deletion test/js/node/stream/node-stream.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@ import { describe, expect, it, jest } from "bun:test";
import { bunEnv, bunExe, isGlibcVersionAtLeast, isMacOS, tmpdirSync } from "harness";
import { createReadStream, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { Duplex, finished, PassThrough, Readable, Stream, Transform, Writable } from "node:stream";
import {
Duplex,
finished,
isDisturbed,
isErrored,
isReadable,
isWritable,
PassThrough,
Readable,
Stream,
Transform,
Writable,
} from "node:stream";
import { finished as finishedP } from "node:stream/promises";
import { join } from "path";

Expand Down Expand Up @@ -1601,3 +1613,123 @@ describe("stream operators argument validation (nodejs/node#59529)", () => {
}
});
});

// Node documents WHATWG web streams as valid operands for these helpers and brands its own
// web stream classes with Symbol.for("nodejs.stream.*") getters. Bun's streams are native and
// don't carry those symbols, so the helpers read the internal [[state]]/[[disturbed]] directly.
describe("isReadable/isWritable/isErrored/isDisturbed on WHATWG web streams", () => {
const probe = stream => ({
isReadable: isReadable(stream),
isWritable: isWritable(stream),
isErrored: isErrored(stream),
isDisturbed: isDisturbed(stream),
});

it("ReadableStream: readable", () => {
const rs = new ReadableStream({
start(c) {
c.enqueue("x");
},
});
expect(probe(rs)).toEqual({ isReadable: true, isWritable: null, isErrored: false, isDisturbed: false });
});

it("ReadableStream: closed", () => {
const rs = new ReadableStream({
start(c) {
c.close();
},
});
expect(probe(rs)).toEqual({ isReadable: false, isWritable: null, isErrored: false, isDisturbed: false });
});

it("ReadableStream: errored + disturbed after read()", async () => {
const rs = new ReadableStream({
start(c) {
c.error(new Error("boom"));
},
});
await rs
.getReader()
.read()
.catch(() => {});
expect(probe(rs)).toEqual({ isReadable: false, isWritable: null, isErrored: true, isDisturbed: true });
});

it("ReadableStream: disturbed after cancel()", async () => {
const rs = new ReadableStream({
start(c) {
c.enqueue("x");
},
});
await rs.cancel();
expect(probe(rs)).toEqual({ isReadable: false, isWritable: null, isErrored: false, isDisturbed: true });
});

it("ReadableStream: disturbed after successful read(), still readable", async () => {
const rs = new ReadableStream({
start(c) {
c.enqueue("x");
},
});
const reader = rs.getReader();
await reader.read();
reader.releaseLock();
expect(probe(rs)).toEqual({ isReadable: true, isWritable: null, isErrored: false, isDisturbed: true });
});

it("WritableStream: writable", () => {
const ws = new WritableStream({ write() {} });
expect(probe(ws)).toEqual({ isReadable: null, isWritable: true, isErrored: false, isDisturbed: false });
});

it("WritableStream: closed", async () => {
const ws = new WritableStream({ write() {} });
const writer = ws.getWriter();
await writer.close();
writer.releaseLock();
expect(probe(ws)).toEqual({ isReadable: null, isWritable: false, isErrored: false, isDisturbed: false });
});

it("WritableStream: errored", async () => {
const ws = new WritableStream({
start(c) {
c.error(new Error("boom"));
},
});
await ws.getWriter().closed.catch(() => {});
expect(probe(ws)).toEqual({ isReadable: null, isWritable: false, isErrored: true, isDisturbed: false });
});

it("TransformStream falls through (no web-stream brand)", () => {
const ts = new TransformStream();
expect(probe(ts)).toEqual({ isReadable: null, isWritable: null, isErrored: false, isDisturbed: false });
});

it("Symbol.for('nodejs.stream.*') overrides still take precedence", () => {
const rs = new ReadableStream({
start(c) {
c.enqueue("x");
},
});
Object.defineProperty(rs, Symbol.for("nodejs.stream.readable"), { value: false });
expect(isReadable(rs)).toBe(false);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("node Readable/Writable operands are unaffected", () => {
const r = new Readable({ read() {} });
expect(isReadable(r)).toBe(true);
expect(isErrored(r)).toBe(false);
expect(isDisturbed(r)).toBe(false);
r.destroy();

const w = new Writable({
write(chunk, enc, cb) {
cb();
},
});
expect(isWritable(w)).toBe(true);
expect(isErrored(w)).toBe(false);
w.destroy();
});
});
Loading