Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
38 changes: 38 additions & 0 deletions src/jsc/JSValue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2463,6 +2463,44 @@ impl JSValue {
) -> JsResult<()> {
self.for_each(global, ctx, callback)
}
/// Materialize an iterable into a new `JSArray` by running the JS
/// iteration protocol. The returned array keeps the yielded values
/// GC-reachable (values from a generator have no other root).
/// Iteration runs user JS and may throw.
pub fn to_array_from_iterable(self, global: &JSGlobalObject) -> JsResult<JSValue> {
struct Ctx {
array: JSValue,
index: u32,
error: Option<JsError>,
}
extern "C" fn append(
_: *mut crate::VM,
global: &JSGlobalObject,
ctx: *mut c_void,
item: JSValue,
) {
// SAFETY: `ctx` is the stack `Ctx` passed to `for_each` below; it
// outlives the iteration.
let ctx = unsafe { &mut *ctx.cast::<Ctx>() };
if ctx.error.is_some() {
return;
}
match ctx.array.put_index(global, ctx.index, item) {
Ok(()) => ctx.index += 1,
Err(err) => ctx.error = Some(err),
}
}
let mut ctx = Ctx {
array: crate::JSArray::create_empty(global, 0)?,
index: 0,
error: None,
};
self.for_each(global, std::ptr::from_mut(&mut ctx).cast(), append)?;
if let Some(err) = ctx.error {
return Err(err);
}
Ok(ctx.array)
}
/// `JSValue.forEachProperty` — enumerate own props,
/// invoking `callback` per (key, value, is_symbol, is_private_symbol).
///
Expand Down
134 changes: 87 additions & 47 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2042,19 +2042,15 @@ impl BlobExt for Blob {
// index the full fixed-3 array (args[2] is written below regardless of len).
let args = &mut arguments_.ptr[..];

if self.size.get() == 0 {
let ptr = Blob::new(Blob::init_empty(global_this));
// SAFETY: `ptr` just came from `heap::alloc` in `Blob::new`; force
// the inherent `Blob::to_js(&mut self)` over `JsClass::to_js`.
return Ok(unsafe { BlobExt::to_js(&*ptr, global_this) });
}

// If the optional start parameter is not used as a parameter, let relativeStart be 0.
let mut relative_start: i64 = 0;
// If the optional end parameter is not used, let relativeEnd be size.
let mut relative_end: i64 = i64::try_from(self.size.get()).expect("int cast");
let size = i64::try_from(self.size.get()).expect("int cast");
let mut relative_end: i64 = size;

// Mutate the fixed-3 args array in place to shift the string arg into [2].
// Bun extension (documented on `BunFile`/`S3File`): a string in the
// `start`/`end` position is the `contentType`, as in `slice(type)`
// and `slice(begin, type)`. Shift it into args[2] and clear its slot.
if args[0].is_string() {
args[2] = args[0];
args[1] = JSValue::ZERO;
Expand All @@ -2064,51 +2060,77 @@ impl BlobExt for Blob {
args[1] = JSValue::ZERO;
}

// WebIDL `[Clamp] long long`: every provided, non-`undefined` value
// goes through ToNumber (`null` becomes 0, objects go through
// `valueOf`, symbols throw); NaN becomes 0. Absent and `undefined`
// keep the spec default.
fn relative_index(
value: JSValue,
global: &JSGlobalObject,
size: i64,
default: i64,
) -> JsResult<i64> {
if value.is_empty() || value.is_undefined() {
return Ok(default);
}
let int = if value.is_number() {
value.to_int64()
} else {
let number = value.to_number(global)?;
// Saturating truncation, mirroring `JSValue::to_int64`.
if number.is_nan() { 0 } else { number as i64 }
};
Ok(if int < 0 {
int.wrapping_add(size).max(0)
} else {
int.min(size)
})
}

let mut args_iter = jsc::ArgumentsSlice::init(global_this.bun_vm(), &arguments_.ptr[..3]);
if let Some(start_) = args_iter.next_eat() {
if start_.is_number() {
let start = start_.to_int64();
if start < 0 {
relative_start = (start
.wrapping_add(i64::try_from(self.size.get()).expect("int cast")))
.max(0);
} else {
relative_start = start.min(i64::try_from(self.size.get()).expect("int cast"));
}
}
relative_start = relative_index(start_, global_this, size, relative_start)?;
}

if let Some(end_) = args_iter.next_eat() {
if end_.is_number() {
let end = end_.to_int64();
if end < 0 {
relative_end = (end
.wrapping_add(i64::try_from(self.size.get()).expect("int cast")))
.max(0);
} else {
relative_end = end.min(i64::try_from(self.size.get()).expect("int cast"));
}
}
relative_end = relative_index(end_, global_this, size, relative_end)?;
Comment thread
robobun marked this conversation as resolved.
}

let mut content_type = BlobContentType::default();
if let Some(content_type_) = args_iter.next_eat() {
'inner: {
if content_type_.is_string() {
let zig_str = content_type_.get_zig_string(global_this)?;
let slicer = zig_str.to_slice();
let slice = slicer.slice();
if !is_valid_blob_type(slice) {
break 'inner;
}
content_type = match global_this.bun_vm().as_mut().mime_type(slice) {
Some(mime) => BlobContentType::from(mime),
None => BlobContentType::from_lowercased(slice),
};
// WebIDL `DOMString`: stringify any provided, non-`undefined`
// value (numbers included), not just string primitives.
if content_type_.is_empty() || content_type_.is_undefined() {
break 'inner;
}
let content_type_str = content_type_.to_slice(global_this)?;
let slice = content_type_str.slice();
if !is_valid_blob_type(slice) {
break 'inner;
}
content_type = match global_this.bun_vm().as_mut().mime_type(slice) {
Some(mime) => BlobContentType::from(mime),
None => BlobContentType::from_lowercased(slice),
};
}
}

// WebIDL converts every argument before the operation body runs, so
// the coercions above (and their side effects and exceptions) happen
// even when this Blob is empty; the provided type still applies.
if self.size.get() == 0 {
let blob = Blob::init_empty(global_this);
let content_type_was_allocated = content_type.is_owned() && !content_type.is_empty();
blob.content_type.set(content_type);
blob.content_type_was_set
.set(self.content_type_was_set.get() || content_type_was_allocated);
let ptr = Blob::new(blob);
// SAFETY: `ptr` just came from `heap::alloc` in `Blob::new`; force
// the inherent `Blob::to_js(&mut self)` over `JsClass::to_js`.
return Ok(unsafe { BlobExt::to_js(&*ptr, global_this) });
}

Ok(self.get_slice_from(global_this, relative_start, relative_end, content_type))
}

Expand Down Expand Up @@ -3304,13 +3326,25 @@ impl BlobExt for Blob {
) -> JsResult<Blob> {
let mut current = arg;
if current.is_undefined_or_null() {
// `new Blob(undefined)` omits the optional `blobParts` argument, but
// `null` is not convertible to a sequence (TypeError in the spec,
// Node, and browsers).
if REQUIRE_ARRAY && current.is_null() {
return Err(
global.throw_invalid_arguments(format_args!("new Blob() expects an Array"))
);
}
Comment thread
robobun marked this conversation as resolved.
return Ok(Blob::init_empty(global));
}

let mut top_value = current;
let might_only_be_one_thing: bool;
arg.ensure_still_alive();
let _keep = jsc::EnsureStillAlive(arg);
// Set when a non-array iterable argument is materialized into a JSArray
// below: that array is the only GC root for values yielded by a
// generator, so it must stay alive until `joiner.done()`.
let mut _keep_iterable_parts = jsc::EnsureStillAlive(JSValue::UNDEFINED);
let mut fail_if_top_value_is_not_typed_array_like = false;
match current.js_type_loose() {
jsc::JSType::Array | jsc::JSType::DerivedArray => {
Expand Down Expand Up @@ -3441,9 +3475,16 @@ impl BlobExt for Blob {
// new Blob("ok")
// new File("ok", "file.txt")
if fail_if_top_value_is_not_typed_array_like {
return Err(
global.throw_invalid_arguments(format_args!("new Blob() expects an Array"))
);
// WebIDL `sequence<BlobPart>` accepts any iterable object
// (Set, Map, generators, ...): materialize it into a JS array
// and join it like one below. Non-iterables still throw.
if !top_value.is_object() || !top_value.is_iterable(global)? {
return Err(
global.throw_invalid_arguments(format_args!("new Blob() expects an Array"))
);
}
current = top_value.to_array_from_iterable(global)?;
_keep_iterable_parts = jsc::EnsureStillAlive(current);
Comment thread
robobun marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -3513,11 +3554,10 @@ impl BlobExt for Blob {
}

while let Some(item) = iter.next()? {
if item.is_undefined_or_null() {
continue;
}

{
// `null`/`undefined` parts are non-cells (`JSType::Cell`
// here) and stringify to "null"/"undefined" below, per
// WebIDL's ToString on non-Blob/BufferSource parts.
match item.js_type_loose() {
jsc::JSType::NumberObject
| jsc::JSType::Cell
Expand Down
3 changes: 2 additions & 1 deletion test/js/web/fetch/blob-array-fast-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ describe("JSArrayIterator butterfly fast path", () => {
arr[0] = "first";
arr[100] = "last";
const blob = new Blob(arr);
expect(await blob.text()).toBe("firstlast");
// holes read as `undefined`, which stringifies (same as Node and browsers)
expect(await blob.text()).toBe("first" + "undefined".repeat(99) + "last");
});

test("hole + Array.prototype indexed getter consults prototype (slow path)", async () => {
Expand Down
96 changes: 94 additions & 2 deletions test/js/web/fetch/blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ for (const info of [
expect(blob.slice(0, Infinity).size).toBe(blob.size);
expect(blob.slice(-Infinity).size).toBe(blob.size);
expect(blob.slice(0, NaN).size).toBe(0);
// `start`/`end` go through ToNumber, so values it rejects throw like in Node and browsers
// @ts-expect-error
expect(blob.slice(Symbol(), "-123").size).toBe(6);
expect(blob.slice(Object.create(null), "-123").size).toBe(6);
expect(() => blob.slice(Symbol(), "-123")).toThrow(TypeError);
expect(() => blob.slice(Object.create(null), "-123")).toThrow(TypeError);
// @ts-expect-error
expect(blob.slice(null, "-123").size).toBe(6);
expect(blob.slice(0, 10).size).toBe(blob.size);
Expand Down Expand Up @@ -112,6 +113,97 @@ test("new Blob stringifies non-Blob object parts in order", async () => {
expect(await new Blob(["a", ["x", "y"], "b"]).text()).toBe("ax,yb");
});

test("new Blob stringifies null and undefined parts instead of dropping them", async () => {
expect(await new Blob([null] as any[]).text()).toBe("null");
expect(await new Blob([undefined] as any[]).text()).toBe("undefined");
expect(await new Blob(["a", null, "b", undefined] as any[]).text()).toBe("anullbundefined");
// a hole reads as `undefined`
expect(await new Blob([, "x"] as any[]).text()).toBe("undefinedx");

const blob = new Blob([null, undefined, 1, {}, true] as any[]);
expect(await blob.text()).toBe("nullundefined1[object Object]true");
expect(blob.size).toBe(33);

expect(await new File(["a", null] as any[], "f.txt").text()).toBe("anull");
});

test("new Blob(null) throws and new Blob(undefined) is an empty Blob", () => {
// null is not convertible to a sequence<BlobPart>
// @ts-expect-error
expect(() => new Blob(null)).toThrow(TypeError);
// @ts-expect-error
expect(() => new File(null, "f.txt")).toThrow(TypeError);
// undefined means the optional argument was omitted
expect(new Blob(undefined).size).toBe(0);
expect(new Blob().size).toBe(0);
});

test("new Blob accepts any iterable of parts", async () => {
expect(await new Blob(new Set(["a", "b"]) as any).text()).toBe("ab");
expect(await new Blob(new Map([["a", "b"]]) as any).text()).toBe("a,b");
expect(new Blob(new Set() as any).size).toBe(0);
expect(await new Blob(new Set([new Blob(["inner"]), "!"]) as any).text()).toBe("inner!");
expect(await new File(new Set(["a"]) as any, "f.txt").text()).toBe("a");

function* parts() {
yield "x";
yield null;
yield 3;
yield new Uint8Array([33]);
}
expect(await new Blob(parts() as any).text()).toBe("xnull3!");

// values produced by a generator are only reachable through the constructor
function* garbage() {
for (let i = 0; i < 16; i++) {
yield `chunk${i};`;
Bun.gc(true);
}
}
expect(await new Blob(garbage() as any).text()).toBe(Array.from({ length: 16 }, (_, i) => `chunk${i};`).join(""));

// errors thrown while iterating propagate
function* throws() {
yield "x";
throw new RangeError("mid-iteration");
}
expect(() => new Blob(throws() as any)).toThrow(RangeError);

// non-iterable objects and primitives are still rejected
expect(() => new Blob({} as any)).toThrow(TypeError);
expect(() => new Blob({ length: 1, 0: "a" } as any)).toThrow(TypeError);
expect(() => new Blob(123 as any)).toThrow(TypeError);
expect(() => new Blob("ab" as any)).toThrow(TypeError);
});

test("Blob.slice applies ToNumber to start/end and ToString to contentType", async () => {
const blob = new Blob(["hello world"]); // 11 bytes
// null is provided, so it coerces to 0 instead of being ignored
expect(blob.slice(null as any, null as any).size).toBe(0);
expect(blob.slice(0, null as any).size).toBe(0);
expect(await blob.slice(null as any, 3).text()).toBe("hel");
// objects coerce through valueOf
expect(await blob.slice({ valueOf: () => 2 } as any, 4).text()).toBe("ll");
expect(await blob.slice(false as any, true as any).text()).toBe("h");
// undefined still means "not provided"
expect(blob.slice(undefined, undefined).size).toBe(11);
expect(blob.slice(undefined, undefined, undefined).type).toBe("");
// a non-string contentType is stringified
expect(blob.slice(1, 3, 123 as any).type).toBe("123");
expect(blob.slice(1, 3, { toString: () => "text/x" } as any).type).toBe("text/x");
// values ToNumber rejects throw
expect(() => blob.slice(Symbol() as any)).toThrow(TypeError);

// arguments are converted before the empty-blob fast path returns
const empty = new Blob([]);
expect(() => empty.slice(Symbol() as any)).toThrow(TypeError);
let valueOfCalls = 0;
expect(empty.slice({ valueOf: () => (valueOfCalls++, 0) } as any).size).toBe(0);
expect(valueOfCalls).toBe(1);
expect(empty.slice(0, 0, 123 as any).type).toBe("123");
expect(empty.slice(0, 0, "text/HTML").type).toBe("text/html");
});

test("blob: can be fetched", async () => {
const blob = new Blob(["Bun", "Foo"]);
const url = URL.createObjectURL(blob);
Expand Down
Loading