Merge pull request #2181 from lightpanda-io/blob_constructor

Support more types in new Blob(...)
This commit is contained in:
Karl Seguin
2026-04-18 10:02:20 +08:00
committed by GitHub
6 changed files with 256 additions and 157 deletions

View File

@@ -163,6 +163,53 @@ pub fn isFloat64Array(self: Value) bool {
return v8.v8__Value__IsFloat64Array(self.handle);
}
// A few places in the code take various types, but want a string. This is a
// type-aware version of toString(). If you do:
// (new ArrayBuffer(100)).toString()
// You'll get "[object ArrayBuffer]". But this `toStringSmart()` knows about
// buffers, and Blobs, etc and will try to return the real underlying string
// value. It _does_ ultimately fallback to toString() - callers should check
// for types they _don't_ want before calling this. For example, `Response`
// checks for null or undefined before calling this to apply specific handling
// to those cases.
pub fn toStringSmart(self: Value) ![]const u8 {
if (self.isString()) |js_str| {
return try js_str.toSlice();
}
const Blob = @import("../webapi/Blob.zig");
if (self.local.jsValueToZig(*Blob, self)) |blob_obj| {
return blob_obj._slice;
} else |_| {}
var byte_offset: usize = 0;
var byte_len: usize = undefined;
var array_buffer: ?*const v8.ArrayBuffer = null;
if (self.isTypedArray() or self.isArrayBufferView()) {
const buffer_handle: *const v8.ArrayBufferView = @ptrCast(self.handle);
byte_len = v8.v8__ArrayBufferView__ByteLength(buffer_handle);
byte_offset = v8.v8__ArrayBufferView__ByteOffset(buffer_handle);
array_buffer = v8.v8__ArrayBufferView__Buffer(buffer_handle);
} else if (self.isArrayBuffer()) {
array_buffer = @ptrCast(self.handle);
byte_len = v8.v8__ArrayBuffer__ByteLength(array_buffer);
} else {
return self.toStringSlice();
}
const backing_store_ptr = v8.v8__ArrayBuffer__GetBackingStore(array_buffer orelse return "");
if (byte_len == 0) {
return &[_]u8{};
}
const backing_store_handle = v8.std__shared_ptr__v8__BackingStore__get(&backing_store_ptr) orelse return "";
const data = v8.v8__BackingStore__Data(backing_store_handle) orelse return "";
const base = @as([*]const u8, @ptrCast(data)) + byte_offset;
return base[0..byte_len];
}
pub fn isPromise(self: Value) bool {
return v8.v8__Value__IsPromise(self.handle);
}

View File

@@ -79,6 +79,100 @@
}
</script>
<script id=parts>
// Blob as a blob part - contents are copied in, not stringified.
{
const inner = new Blob(["hello "], { type: "text/plain" });
const blob = new Blob([inner, "world"]);
testing.expectEqual(11, blob.size);
testing.async(async () => {
testing.expectEqual("hello world", await blob.text());
});
}
// Uint8Array as a blob part.
{
const bytes = new Uint8Array([104, 101, 108, 108, 111]); // "hello"
const blob = new Blob([bytes, " ", "world"]);
testing.expectEqual(11, blob.size);
testing.async(async () => {
testing.expectEqual("hello world", await blob.text());
});
}
// Non-byte TypedArrays contribute their raw backing bytes.
{
// Uint16Array of one element (0x0041 = 'A' little-endian) produces 2 bytes.
const u16 = new Uint16Array([0x0041]);
const blob = new Blob([u16]);
testing.expectEqual(2, blob.size);
}
{
// Float32Array: 4 bytes per element.
const f32 = new Float32Array([1.0, 2.0, 3.0]);
const blob = new Blob([f32]);
testing.expectEqual(12, blob.size);
}
// ArrayBuffer as a blob part.
{
const buf = new Uint8Array([1, 2, 3, 4]).buffer;
const blob = new Blob([buf]);
testing.expectEqual(4, blob.size);
testing.async(async () => {
const result = await blob.bytes();
testing.expectEqual(new Uint8Array([1, 2, 3, 4]), result);
});
}
// DataView (ArrayBufferView) as a blob part.
{
const buf = new Uint8Array([10, 20, 30, 40, 50]).buffer;
const view = new DataView(buf, 1, 3); // bytes [20, 30, 40]
const blob = new Blob([view]);
testing.expectEqual(3, blob.size);
testing.async(async () => {
const result = await blob.bytes();
testing.expectEqual(new Uint8Array([20, 30, 40]), result);
});
}
// Mixed types in a single parts array.
{
const inner = new Blob(["bb"]);
const bytes = new Uint8Array([99, 99]); // "cc"
const buf = new Uint8Array([100, 100]).buffer; // "dd"
const blob = new Blob(["aa", inner, bytes, buf, "ee"]);
testing.expectEqual(10, blob.size);
testing.async(async () => {
testing.expectEqual("aabbccddee", await blob.text());
});
}
// Number coerces to string.
{
const blob = new Blob([42]);
testing.expectEqual(2, blob.size);
testing.async(async () => {
testing.expectEqual("42", await blob.text());
});
}
// Empty parts array.
{
const blob = new Blob([]);
testing.expectEqual(0, blob.size);
testing.expectEqual("", blob.type);
}
// No arguments.
{
const blob = new Blob();
testing.expectEqual(0, blob.size);
}
</script>
<script id=stream>
{
const parts = ["may", "thy", "knife", "chip", "and", "shatter"];

View File

@@ -59,69 +59,23 @@ const InitOptions = struct {
endings: []const u8 = "transparent",
};
/// Creates a new Blob (JS constructor).
pub fn init(
maybe_blob_parts: ?[]const []const u8,
maybe_options: ?InitOptions,
page: *Page,
) !*Blob {
return initWithMimeValidation(maybe_blob_parts, maybe_options, false, page);
}
/// Creates a new Blob with optional MIME validation.
/// When validate_mime is true, uses full MIME parsing (for Response/Request).
/// When false, uses simple ASCII validation per FileAPI spec (for Blob constructor).
pub fn initWithMimeValidation(
maybe_blob_parts: ?[]const []const u8,
maybe_options: ?InitOptions,
validate_mime: bool,
page: *Page,
) !*Blob {
const data_len = blk: {
const parts = maybe_blob_parts orelse break :blk 0;
var size: usize = 0;
for (parts) |p| {
size += p.len;
}
break :blk size;
};
const arena = try page.getArena(256 + data_len, "Blob");
/// Creates a new Blob from JS values with optional MIME validation.
/// This is the JS Constructor
pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, page: *Page) !*Blob {
const arena = try page.getArena(.large, "Blob");
errdefer page.releaseArena(arena);
const options: InitOptions = maybe_options orelse .{};
const mime: []const u8 = blk: {
const t = options.type;
if (t.len == 0) {
break :blk "";
}
const buf = try arena.dupe(u8, t);
if (validate_mime) {
// Full MIME parsing per MIME sniff spec (for Content-Type headers)
_ = Mime.parse(buf) catch break :blk "";
} else {
// Simple validation per FileAPI spec (for Blob constructor):
// - If any char is outside U+0020-U+007E, return empty string
// - Otherwise lowercase
for (t) |c| {
if (c < 0x20 or c > 0x7E) {
break :blk "";
}
}
_ = std.ascii.lowerString(buf, buf);
}
break :blk buf;
};
const opts: InitOptions = opts_ orelse .{};
const mime = try validateMimeType(arena, opts.type, false);
const data = blk: {
if (maybe_blob_parts) |blob_parts| {
if (parts_) |blob_parts| {
const use_native_endings = std.mem.eql(u8, opts.endings, "native");
var w: Writer.Allocating = .init(arena);
const use_native_endings = std.mem.eql(u8, options.endings, "native");
try writeBlobParts(&w.writer, blob_parts, use_native_endings);
for (blob_parts) |js_val| {
const part = try js_val.toStringSmart();
try writePartWithEndings(part, use_native_endings, &w.writer);
}
break :blk w.written();
}
@@ -139,6 +93,50 @@ pub fn initWithMimeValidation(
return self;
}
/// Creates a new Blob from raw byte slices (for internal Zig use).
pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime: bool, page: *Page) !*Blob {
const arena = try page.getArena(.large, "Blob");
errdefer page.releaseArena(arena);
const mime = try validateMimeType(arena, content_type, validate_mime);
const self = try arena.create(Blob);
self.* = .{
._rc = .{},
._arena = arena,
._type = .generic,
._slice = try arena.dupe(u8, data),
._mime = mime,
};
return self;
}
/// Validates and normalizes MIME type according to spec.
fn validateMimeType(arena: Allocator, mime_type: []const u8, full_validation: bool) ![]const u8 {
if (mime_type.len == 0) {
return "";
}
const buf = try arena.dupe(u8, mime_type);
if (full_validation) {
// Full MIME parsing per MIME sniff spec (for Content-Type headers)
_ = Mime.parse(buf) catch return "";
} else {
// Simple validation per FileAPI spec (for Blob constructor):
// - If any char is outside U+0020-U+007E, return empty string
// - Otherwise lowercase
for (mime_type) |c| {
if (c < 0x20 or c > 0x7E) {
return "";
}
}
_ = std.ascii.lowerString(buf, buf);
}
return buf;
}
pub fn deinit(self: *Blob, session: *Session) void {
session.releaseArena(self._arena);
}
@@ -171,18 +169,11 @@ const vector_sizes = blk: {
break :blk items;
};
/// Writes blob parts to given `Writer` with desired endings.
fn writeBlobParts(
writer: *Writer,
blob_parts: []const []const u8,
use_native_endings: bool,
) !void {
// Transparent.
/// Writes a single part with optional line ending normalization.
fn writePartWithEndings(part: []const u8, use_native_endings: bool, writer: *Writer) !void {
// Transparent - no conversion needed.
if (!use_native_endings) {
for (blob_parts) |part| {
try writer.writeAll(part);
}
try writer.writeAll(part);
return;
}
@@ -204,68 +195,66 @@ fn writeBlobParts(
// ```
// "the quick\n\nbrown fox"
// ```
scan_parts: for (blob_parts) |part| {
var end: usize = 0;
var end: usize = 0;
inline for (vector_sizes) |vector_len| {
const Vec = @Vector(vector_len, u8);
inline for (vector_sizes) |vector_len| {
const Vec = @Vector(vector_len, u8);
while (end + vector_len <= part.len) : (end += vector_len) {
const cr: Vec = @splat('\r');
// Load chunk as vectors.
const data = part[end..][0..vector_len];
const chunk: Vec = data.*;
// Look for CR.
const match = chunk == cr;
while (end + vector_len <= part.len) : (end += vector_len) {
const cr: Vec = @splat('\r');
// Load chunk as vectors.
const data = part[end..][0..vector_len];
const chunk: Vec = data.*;
// Look for CR.
const match = chunk == cr;
// Create a bitset out of match vector.
const bitset = std.bit_set.IntegerBitSet(vector_len){
.mask = @bitCast(@intFromBool(match)),
};
// Create a bitset out of match vector.
const bitset = std.bit_set.IntegerBitSet(vector_len){
.mask = @bitCast(@intFromBool(match)),
};
var iter = bitset.iterator(.{});
var relative_start: usize = 0;
while (iter.next()) |index| {
_ = try writer.writeVec(&.{ data[relative_start..index], "\n" });
var iter = bitset.iterator(.{});
var relative_start: usize = 0;
while (iter.next()) |index| {
_ = try writer.writeVec(&.{ data[relative_start..index], "\n" });
if (index + 1 != data.len and data[index + 1] == '\n') {
relative_start = index + 2;
} else {
relative_start = index + 1;
}
}
_ = try writer.writeVec(&.{data[relative_start..]});
}
}
// Scalar scan fallback.
var relative_start: usize = end;
while (end < part.len) {
if (part[end] == '\r') {
_ = try writer.writeVec(&.{ part[relative_start..end], "\n" });
// Part ends with CR. We can continue to next part.
if (end + 1 == part.len) {
continue :scan_parts;
}
// If next char is LF, skip it too.
if (part[end + 1] == '\n') {
relative_start = end + 2;
if (index + 1 != data.len and data[index + 1] == '\n') {
relative_start = index + 2;
} else {
relative_start = end + 1;
relative_start = index + 1;
}
}
end += 1;
_ = try writer.writeVec(&.{data[relative_start..]});
}
}
// Scalar scan fallback.
var relative_start: usize = end;
while (end < part.len) {
if (part[end] == '\r') {
_ = try writer.writeVec(&.{ part[relative_start..end], "\n" });
// Part ends with CR. We need to remember this for next part.
if (end + 1 == part.len) {
return;
}
// If next char is LF, skip it too.
if (part[end + 1] == '\n') {
relative_start = end + 2;
} else {
relative_start = end + 1;
}
}
// Write the remaining. We get this in such situations:
// `the quick brown\rfox`
// `the quick brown\r\nfox`
try writer.writeAll(part[relative_start..end]);
end += 1;
}
// Write the remaining. We get this in such situations:
// `the quick brown\rfox`
// `the quick brown\r\nfox`
try writer.writeAll(part[relative_start..end]);
}
/// Returns a Promise that resolves with the contents of the blob
@@ -323,7 +312,7 @@ pub fn slice(
break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end))));
};
return Blob.init(&.{data[start..end]}, .{ .type = content_type_ orelse "" }, page);
return Blob.initFromBytes(data[start..end], content_type_ orelse "", false, page);
}
/// Returns the size of the Blob in bytes.

View File

@@ -174,12 +174,7 @@ pub fn blob(self: *Request, page: *Page) !js.Promise {
const headers = try self.getHeaders(page);
const content_type = try headers.get("content-type", page) orelse "";
const b = try Blob.initWithMimeValidation(
&.{body},
.{ .type = content_type },
true,
page,
);
const b = try Blob.initFromBytes(body, content_type, true, page);
return page.js.local.?.resolvePromise(b);
}

View File

@@ -83,29 +83,10 @@ pub fn init(body_: ?BodyInit, opts_: ?InitOpts, page: *Page) !*Response {
.bytes => |body_bytes| break :blk .{ .bytes = try arena.dupe(u8, body_bytes) },
.stream => |stream| break :blk .{ .stream = stream },
.js_val => |js_val| {
const local = page.js.local.?;
if (local.jsValueToZig(*ReadableStream, js_val)) |stream| {
break :blk .{ .stream = stream };
} else |_| {}
if (js_val.isString()) |js_str| {
break :blk .{ .bytes = try js_str.toSliceWithAlloc(arena) };
}
if (js_val.isArrayBuffer() or js_val.isTypedArray() or js_val.isArrayBufferView()) {
if (local.jsValueToZig([]u8, js_val)) |data| {
break :blk .{ .bytes = try arena.dupe(u8, data) };
} else |_| {}
}
if (local.jsValueToZig(*Blob, js_val)) |blob_obj| {
break :blk .{ .bytes = try arena.dupe(u8, blob_obj._slice) };
} else |_| {}
if (js_val.isNullOrUndefined() == false) {
break :blk .{ .bytes = try js_val.toStringSliceWithAlloc(arena) };
if (js_val.isNullOrUndefined()) {
break :blk .empty;
}
break :blk .{ .bytes = try arena.dupe(u8, try js_val.toStringSmart()) };
},
}
break :blk .empty;
@@ -335,14 +316,7 @@ pub fn blob(self: *const Response, page: *Page) !js.Promise {
.stream => return local.rejectPromise(.{ .type_error = "Cannot read blob from stream body" }),
};
const content_type = try self._headers.get("content-type", page) orelse "";
const b = try Blob.initWithMimeValidation(
&.{body},
.{ .type = content_type },
true,
page,
);
const b = try Blob.initFromBytes(body, content_type, true, page);
return local.resolvePromise(b);
}

View File

@@ -466,7 +466,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF
switch (self._binary_type) {
.arraybuffer => .{ .arraybuffer = .{ .values = data } },
.blob => blk: {
const blob = try Blob.init(&.{data}, .{}, page);
const blob = try Blob.initFromBytes(data, "", false, page);
blob.acquireRef();
break :blk .{ .blob = blob };
},