Preserve chunk value types through ReadableStream enqueue/read

When JS called controller.enqueue(42), the value was coerced to the
string "42" because Chunk only had uint8array and string variants.
Add a js_value variant that persists the raw JS value handle, and
expose enqueueValue(js.Value) as the JS-facing enqueue method so
numbers, booleans, and objects round-trip with their original types.
This commit is contained in:
Pierre Tachoire
2026-03-02 14:24:49 +01:00
parent ca0ef18bdf
commit 0749f60702
4 changed files with 116 additions and 2 deletions

View File

@@ -301,3 +301,74 @@
testing.expectEqual(false, data3.done); testing.expectEqual(false, data3.done);
})(); })();
</script> </script>
<script id=enqueue_preserves_number>
(async function() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(42);
controller.enqueue(0);
controller.enqueue(3.14);
controller.close();
}
});
const reader = stream.getReader();
const r1 = await reader.read();
testing.expectEqual(false, r1.done);
testing.expectEqual('number', typeof r1.value);
testing.expectEqual(42, r1.value);
const r2 = await reader.read();
testing.expectEqual('number', typeof r2.value);
testing.expectEqual(0, r2.value);
const r3 = await reader.read();
testing.expectEqual('number', typeof r3.value);
testing.expectEqual(3.14, r3.value);
const r4 = await reader.read();
testing.expectEqual(true, r4.done);
})();
</script>
<script id=enqueue_preserves_bool>
(async function() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(true);
controller.enqueue(false);
controller.close();
}
});
const reader = stream.getReader();
const r1 = await reader.read();
testing.expectEqual('boolean', typeof r1.value);
testing.expectEqual(true, r1.value);
const r2 = await reader.read();
testing.expectEqual('boolean', typeof r2.value);
testing.expectEqual(false, r2.value);
})();
</script>
<script id=enqueue_preserves_object>
(async function() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue({ key: 'value', num: 7 });
controller.close();
}
});
const reader = stream.getReader();
const r1 = await reader.read();
testing.expectEqual('object', typeof r1.value);
testing.expectEqual('value', r1.value.key);
testing.expectEqual(7, r1.value.num);
})();
</script>

View File

@@ -33,11 +33,13 @@ pub const Chunk = union(enum) {
// the order matters, sorry. // the order matters, sorry.
uint8array: js.TypedArray(u8), uint8array: js.TypedArray(u8),
string: []const u8, string: []const u8,
js_value: js.Value.Global,
pub fn dupe(self: Chunk, allocator: std.mem.Allocator) !Chunk { pub fn dupe(self: Chunk, allocator: std.mem.Allocator) !Chunk {
return switch (self) { return switch (self) {
.string => |str| .{ .string = try allocator.dupe(u8, str) }, .string => |str| .{ .string = try allocator.dupe(u8, str) },
.uint8array => |arr| .{ .uint8array = try arr.dupe(allocator) }, .uint8array => |arr| .{ .uint8array = try arr.dupe(allocator) },
.js_value => |val| .{ .js_value = val },
}; };
} }
}; };
@@ -98,6 +100,40 @@ pub fn enqueue(self: *ReadableStreamDefaultController, chunk: Chunk) !void {
ls.toLocal(resolver).resolve("stream enqueue", result); ls.toLocal(resolver).resolve("stream enqueue", result);
} }
/// Enqueue a raw JS value, preserving its type (number, bool, object, etc.).
/// Used by the JS-facing API; internal Zig callers should use enqueue(Chunk).
pub fn enqueueValue(self: *ReadableStreamDefaultController, value: js.Value) !void {
if (self._stream._state != .readable) {
return error.StreamNotReadable;
}
if (self._pending_reads.items.len == 0) {
const persisted = try value.persist();
try self._queue.append(self._arena, .{ .js_value = persisted });
return;
}
const resolver = self._pending_reads.orderedRemove(0);
const persisted = try value.persist();
const result = ReadableStreamDefaultReader.ReadResult{
.done = false,
.value = .{ .js_value = persisted },
};
if (comptime IS_DEBUG) {
if (self._page.js.local == null) {
log.fatal(.bug, "null context scope", .{ .src = "ReadableStreamDefaultController.enqueueValue", .url = self._page.url });
std.debug.assert(self._page.js.local != null);
}
}
var ls: js.Local.Scope = undefined;
self._page.js.localScope(&ls);
defer ls.deinit();
ls.toLocal(resolver).resolve("stream enqueue value", result);
}
pub fn close(self: *ReadableStreamDefaultController) !void { pub fn close(self: *ReadableStreamDefaultController) !void {
if (self._stream._state != .readable) { if (self._stream._state != .readable) {
return error.StreamNotReadable; return error.StreamNotReadable;
@@ -176,7 +212,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
}; };
pub const enqueue = bridge.function(ReadableStreamDefaultController.enqueue, .{}); pub const enqueue = bridge.function(ReadableStreamDefaultController.enqueueValue, .{});
pub const close = bridge.function(ReadableStreamDefaultController.close, .{}); pub const close = bridge.function(ReadableStreamDefaultController.close, .{});
pub const @"error" = bridge.function(ReadableStreamDefaultController.doError, .{}); pub const @"error" = bridge.function(ReadableStreamDefaultController.doError, .{});
pub const desiredSize = bridge.accessor(ReadableStreamDefaultController.getDesiredSize, null, .{}); pub const desiredSize = bridge.accessor(ReadableStreamDefaultController.getDesiredSize, null, .{});

View File

@@ -44,11 +44,13 @@ pub const ReadResult = struct {
empty, empty,
string: []const u8, string: []const u8,
uint8array: js.TypedArray(u8), uint8array: js.TypedArray(u8),
js_value: js.Value.Global,
pub fn fromChunk(chunk: ReadableStreamDefaultController.Chunk) Chunk { pub fn fromChunk(chunk: ReadableStreamDefaultController.Chunk) Chunk {
return switch (chunk) { return switch (chunk) {
.string => |s| .{ .string = s }, .string => |s| .{ .string = s },
.uint8array => |arr| .{ .uint8array = arr }, .uint8array => |arr| .{ .uint8array = arr },
.js_value => |val| .{ .js_value = val },
}; };
} }
}; };

View File

@@ -178,6 +178,11 @@ pub const TransformStreamDefaultController = struct {
try self._stream._readable._controller.enqueue(chunk); try self._stream._readable._controller.enqueue(chunk);
} }
/// Enqueue a raw JS value, preserving its type. Used by the JS-facing API.
pub fn enqueueValue(self: *TransformStreamDefaultController, value: js.Value) !void {
try self._stream._readable._controller.enqueueValue(value);
}
pub fn doError(self: *TransformStreamDefaultController, reason: []const u8) !void { pub fn doError(self: *TransformStreamDefaultController, reason: []const u8) !void {
try self._stream._readable._controller.doError(reason); try self._stream._readable._controller.doError(reason);
} }
@@ -195,7 +200,7 @@ pub const TransformStreamDefaultController = struct {
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
}; };
pub const enqueue = bridge.function(TransformStreamDefaultController.enqueue, .{}); pub const enqueue = bridge.function(TransformStreamDefaultController.enqueueValue, .{});
pub const @"error" = bridge.function(TransformStreamDefaultController.doError, .{}); pub const @"error" = bridge.function(TransformStreamDefaultController.doError, .{});
pub const terminate = bridge.function(TransformStreamDefaultController.terminate, .{}); pub const terminate = bridge.function(TransformStreamDefaultController.terminate, .{});
}; };