diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 5f34a5b9..01adc4f2 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -60,6 +60,11 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) ctx.local = &self.local; } +pub fn initFromHandle(self: *Caller, handle: ?*const v8.FunctionCallbackInfo) void { + const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?; + self.init(isolate); +} + pub fn deinit(self: *Caller) void { const ctx = self.local.ctx; const call_depth = ctx.call_depth - 1; @@ -441,6 +446,11 @@ pub const FunctionCallbackInfo = struct { return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? }; } + pub fn getData(self: FunctionCallbackInfo) ?*anyopaque { + const data = v8.v8__FunctionCallbackInfo__Data(self.handle) orelse return null; + return v8.v8__External__Value(@ptrCast(data)); + } + pub fn getThis(self: FunctionCallbackInfo) *const v8.Object { return v8.v8__FunctionCallbackInfo__This(self.handle).?; } @@ -499,6 +509,7 @@ pub const Function = struct { as_typed_array: bool = false, null_as_undefined: bool = false, cache: ?Caching = null, + embedded_receiver: bool = false, // We support two ways to cache a value directly into a v8::Object. The // difference between the two is like the difference between a Map @@ -569,6 +580,9 @@ pub const Function = struct { var args: ParameterTypes(F) = undefined; if (comptime opts.static) { args = try getArgs(F, 0, local, info); + } else if (comptime opts.embedded_receiver) { + args = try getArgs(F, 1, local, info); + @field(args, "0") = @ptrCast(@alignCast(info.getData() orelse unreachable)); } else { args = try getArgs(F, 1, local, info); @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 6b2e724a..a87d48d0 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -868,13 +868,12 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul const then_callback = newFunctionWithData(local, struct { pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { - const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?; var c: Caller = undefined; - c.init(isolate); + c.initFromHandle(callback_handle); defer c.deinit(); - const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?; - const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data)))); + const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? }; + const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return)); if (s.context_id != c.local.ctx.id) { // The microtask is tied to the isolate, not the context @@ -893,17 +892,15 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul const catch_callback = newFunctionWithData(local, struct { pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { - const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?; var c: Caller = undefined; - c.init(isolate); + c.initFromHandle(callback_handle); defer c.deinit(); - const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?; - const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data)))); + const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? }; + const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return)); const l = &c.local; - const ctx = l.ctx; - if (s.context_id != ctx.id) { + if (s.context_id != l.ctx.id) { return; } diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 5ff6917d..04a65bc7 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -82,6 +82,20 @@ pub fn createTypedArray(self: *const Local, comptime array_type: js.ArrayType, s return .init(self, size); } +pub fn newCallback( + self: *const Local, + callback: anytype, + data: anytype, +) js.Function { + const external = self.isolate.createExternal(data); + const handle = v8.v8__Function__New__DEFAULT2(self.handle, struct { + fn wrap(info_handle: ?*const js.v8.FunctionCallbackInfo) callconv(.c) void { + Caller.Function.call(@TypeOf(data), info_handle.?, callback, .{ .embedded_receiver = true }); + } + }.wrap, @ptrCast(external)).?; + return .{ .local = self, .handle = handle }; +} + pub fn runMacrotasks(self: *const Local) void { const env = self.ctx.env; env.pumpMessageLoop(); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index af4df968..2801f86b 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -827,6 +827,8 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/element/svg/Generic.zig"), @import("../webapi/encoding/TextDecoder.zig"), @import("../webapi/encoding/TextEncoder.zig"), + @import("../webapi/encoding/TextEncoderStream.zig"), + @import("../webapi/encoding/TextDecoderStream.zig"), @import("../webapi/Event.zig"), @import("../webapi/event/CompositionEvent.zig"), @import("../webapi/event/CustomEvent.zig"), @@ -863,6 +865,10 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/streams/ReadableStream.zig"), @import("../webapi/streams/ReadableStreamDefaultReader.zig"), @import("../webapi/streams/ReadableStreamDefaultController.zig"), + @import("../webapi/streams/WritableStream.zig"), + @import("../webapi/streams/WritableStreamDefaultWriter.zig"), + @import("../webapi/streams/WritableStreamDefaultController.zig"), + @import("../webapi/streams/TransformStream.zig"), @import("../webapi/Node.zig"), @import("../webapi/storage/storage.zig"), @import("../webapi/URL.zig"), diff --git a/src/browser/tests/streams/readable_stream.html b/src/browser/tests/streams/readable_stream.html index 3d00d6cf..c82fd985 100644 --- a/src/browser/tests/streams/readable_stream.html +++ b/src/browser/tests/streams/readable_stream.html @@ -301,3 +301,74 @@ testing.expectEqual(false, data3.done); })(); + + + + + + diff --git a/src/browser/tests/streams/text_decoder_stream.html b/src/browser/tests/streams/text_decoder_stream.html new file mode 100644 index 00000000..cf38a2fd --- /dev/null +++ b/src/browser/tests/streams/text_decoder_stream.html @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + diff --git a/src/browser/tests/streams/transform_stream.html b/src/browser/tests/streams/transform_stream.html new file mode 100644 index 00000000..57518915 --- /dev/null +++ b/src/browser/tests/streams/transform_stream.html @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/encoding/TextDecoderStream.zig b/src/browser/webapi/encoding/TextDecoderStream.zig new file mode 100644 index 00000000..a5b2dc9a --- /dev/null +++ b/src/browser/webapi/encoding/TextDecoderStream.zig @@ -0,0 +1,127 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const ReadableStream = @import("../streams/ReadableStream.zig"); +const WritableStream = @import("../streams/WritableStream.zig"); +const TransformStream = @import("../streams/TransformStream.zig"); + +const TextDecoderStream = @This(); + +_transform: *TransformStream, +_fatal: bool, +_ignore_bom: bool, + +const Label = enum { + utf8, + @"utf-8", + @"unicode-1-1-utf-8", +}; + +const InitOpts = struct { + fatal: bool = false, + ignoreBOM: bool = false, +}; + +pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !TextDecoderStream { + if (label_) |label| { + _ = std.meta.stringToEnum(Label, label) orelse return error.RangeError; + } + + const opts = opts_ orelse InitOpts{}; + const decodeFn: TransformStream.ZigTransformFn = blk: { + if (opts.ignoreBOM) { + break :blk struct { + fn decode(controller: *TransformStream.DefaultController, chunk: js.Value) !void { + return decodeTransform(controller, chunk, true); + } + }.decode; + } else { + break :blk struct { + fn decode(controller: *TransformStream.DefaultController, chunk: js.Value) !void { + return decodeTransform(controller, chunk, false); + } + }.decode; + } + }; + + const transform = try TransformStream.initWithZigTransform(decodeFn, page); + + return .{ + ._transform = transform, + ._fatal = opts.fatal, + ._ignore_bom = opts.ignoreBOM, + }; +} + +fn decodeTransform(controller: *TransformStream.DefaultController, chunk: js.Value, ignoreBOM: bool) !void { + // chunk should be a Uint8Array; decode it as UTF-8 string + const typed_array = try chunk.toZig(js.TypedArray(u8)); + var input = typed_array.values; + + // Strip UTF-8 BOM if present + if (ignoreBOM == false and std.mem.startsWith(u8, input, &.{ 0xEF, 0xBB, 0xBF })) { + input = input[3..]; + } + + // Per spec, empty chunks produce no output + if (input.len == 0) return; + + try controller.enqueue(.{ .string = input }); +} + +pub fn getReadable(self: *const TextDecoderStream) *ReadableStream { + return self._transform.getReadable(); +} + +pub fn getWritable(self: *const TextDecoderStream) *WritableStream { + return self._transform.getWritable(); +} + +pub fn getFatal(self: *const TextDecoderStream) bool { + return self._fatal; +} + +pub fn getIgnoreBOM(self: *const TextDecoderStream) bool { + return self._ignore_bom; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TextDecoderStream); + + pub const Meta = struct { + pub const name = "TextDecoderStream"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(TextDecoderStream.init, .{}); + pub const encoding = bridge.property("utf-8", .{ .template = false }); + pub const readable = bridge.accessor(TextDecoderStream.getReadable, null, .{}); + pub const writable = bridge.accessor(TextDecoderStream.getWritable, null, .{}); + pub const fatal = bridge.accessor(TextDecoderStream.getFatal, null, .{}); + pub const ignoreBOM = bridge.accessor(TextDecoderStream.getIgnoreBOM, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: TextDecoderStream" { + try testing.htmlRunner("streams/text_decoder_stream.html", .{}); +} diff --git a/src/browser/webapi/encoding/TextEncoderStream.zig b/src/browser/webapi/encoding/TextEncoderStream.zig new file mode 100644 index 00000000..b2526637 --- /dev/null +++ b/src/browser/webapi/encoding/TextEncoderStream.zig @@ -0,0 +1,70 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const ReadableStream = @import("../streams/ReadableStream.zig"); +const WritableStream = @import("../streams/WritableStream.zig"); +const TransformStream = @import("../streams/TransformStream.zig"); + +const TextEncoderStream = @This(); + +_transform: *TransformStream, + +pub fn init(page: *Page) !TextEncoderStream { + const transform = try TransformStream.initWithZigTransform(&encodeTransform, page); + return .{ + ._transform = transform, + }; +} + +fn encodeTransform(controller: *TransformStream.DefaultController, chunk: js.Value) !void { + // chunk should be a JS string; encode it as UTF-8 bytes (Uint8Array) + const str = chunk.isString() orelse return error.InvalidChunk; + const slice = try str.toSlice(); + try controller.enqueue(.{ .uint8array = .{ .values = slice } }); +} + +pub fn getReadable(self: *const TextEncoderStream) *ReadableStream { + return self._transform.getReadable(); +} + +pub fn getWritable(self: *const TextEncoderStream) *WritableStream { + return self._transform.getWritable(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TextEncoderStream); + + pub const Meta = struct { + pub const name = "TextEncoderStream"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(TextEncoderStream.init, .{}); + pub const encoding = bridge.property("utf-8", .{ .template = false }); + pub const readable = bridge.accessor(TextEncoderStream.getReadable, null, .{}); + pub const writable = bridge.accessor(TextEncoderStream.getWritable, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: TextEncoderStream" { + try testing.htmlRunner("streams/transform_stream.html", .{}); +} diff --git a/src/browser/webapi/streams/ReadableStream.zig b/src/browser/webapi/streams/ReadableStream.zig index 64c1b529..e4e5d0f9 100644 --- a/src/browser/webapi/streams/ReadableStream.zig +++ b/src/browser/webapi/streams/ReadableStream.zig @@ -24,6 +24,7 @@ const Page = @import("../../Page.zig"); const ReadableStreamDefaultReader = @import("ReadableStreamDefaultReader.zig"); const ReadableStreamDefaultController = @import("ReadableStreamDefaultController.zig"); +const WritableStream = @import("WritableStream.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; @@ -233,6 +234,126 @@ pub fn cancel(self: *ReadableStream, reason: ?[]const u8, page: *Page) !js.Promi return resolver.promise(); } +/// pipeThrough(transform) — pipes this readable stream through a transform stream, +/// returning the readable side. `transform` is a JS object with `readable` and `writable` properties. +const PipeTransform = struct { + writable: *WritableStream, + readable: *ReadableStream, +}; +pub fn pipeThrough(self: *ReadableStream, transform: PipeTransform, page: *Page) !*ReadableStream { + if (self.getLocked()) { + return error.ReaderLocked; + } + + // Start async piping from this stream to the writable side + try PipeState.startPipe(self, transform.writable, null, page); + + return transform.readable; +} + +/// pipeTo(writable) — pipes this readable stream to a writable stream. +/// Returns a promise that resolves when piping is complete. +pub fn pipeTo(self: *ReadableStream, destination: *WritableStream, page: *Page) !js.Promise { + if (self.getLocked()) { + return page.js.local.?.rejectPromise("ReadableStream is locked"); + } + + const local = page.js.local.?; + var pipe_resolver = local.createPromiseResolver(); + const promise = pipe_resolver.promise(); + const persisted_resolver = try pipe_resolver.persist(); + + try PipeState.startPipe(self, destination, persisted_resolver, page); + + return promise; +} + +/// State for an async pipe operation. +const PipeState = struct { + reader: *ReadableStreamDefaultReader, + writable: *WritableStream, + context_id: usize, + resolver: ?js.PromiseResolver.Global, + + fn startPipe( + stream: *ReadableStream, + writable: *WritableStream, + resolver: ?js.PromiseResolver.Global, + page: *Page, + ) !void { + const reader = try stream.getReader(page); + const state = try page.arena.create(PipeState); + state.* = .{ + .reader = reader, + .writable = writable, + .context_id = page.js.id, + .resolver = resolver, + }; + try state.pumpRead(page); + } + + fn pumpRead(state: *PipeState, page: *Page) !void { + const local = page.js.local.?; + + // Call reader.read() which returns a Promise + const read_promise = try state.reader.read(page); + + // Create JS callback functions for .then() and .catch() + const then_fn = local.newCallback(onReadFulfilled, state); + const catch_fn = local.newCallback(onReadRejected, state); + + _ = read_promise.thenAndCatch(then_fn, catch_fn) catch { + state.finish(local); + }; + } + + const ReadData = struct { + done: bool, + value: js.Value, + }; + fn onReadFulfilled(self: *PipeState, data_: ?ReadData, page: *Page) void { + const local = page.js.local.?; + const data = data_ orelse { + return self.finish(local); + }; + + if (data.done) { + // Stream is finished, close the writable side + self.writable.closeStream(page) catch {}; + self.reader.releaseLock(); + if (self.resolver) |r| { + local.toLocal(r).resolve("pipeTo complete", {}); + } + return; + } + + const value = data.value; + if (value.isUndefined()) { + return self.finish(local); + } + + self.writable.writeChunk(value, page) catch { + return self.finish(local); + }; + + // Continue reading the next chunk + self.pumpRead(page) catch { + self.finish(local); + }; + } + + fn onReadRejected(self: *PipeState, page: *Page) void { + self.finish(page.js.local.?); + } + + fn finish(self: *PipeState, local: *const js.Local) void { + self.reader.releaseLock(); + if (self.resolver) |r| { + local.toLocal(r).resolve("pipe finished", {}); + } + } +}; + const Cancel = struct { callback: ?js.Function.Global = null, reason: ?[]const u8 = null, @@ -251,6 +372,8 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(ReadableStream.init, .{}); pub const cancel = bridge.function(ReadableStream.cancel, .{}); pub const getReader = bridge.function(ReadableStream.getReader, .{}); + pub const pipeThrough = bridge.function(ReadableStream.pipeThrough, .{}); + pub const pipeTo = bridge.function(ReadableStream.pipeTo, .{}); pub const locked = bridge.accessor(ReadableStream.getLocked, null, .{}); pub const symbol_async_iterator = bridge.iterator(ReadableStream.getAsyncIterator, .{ .async = true }); }; diff --git a/src/browser/webapi/streams/ReadableStreamDefaultController.zig b/src/browser/webapi/streams/ReadableStreamDefaultController.zig index c2f750bc..18228475 100644 --- a/src/browser/webapi/streams/ReadableStreamDefaultController.zig +++ b/src/browser/webapi/streams/ReadableStreamDefaultController.zig @@ -33,11 +33,13 @@ pub const Chunk = union(enum) { // the order matters, sorry. uint8array: js.TypedArray(u8), string: []const u8, + js_value: js.Value.Global, pub fn dupe(self: Chunk, allocator: std.mem.Allocator) !Chunk { return switch (self) { .string => |str| .{ .string = try allocator.dupe(u8, str) }, .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); } +/// 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 { if (self._stream._state != .readable) { return error.StreamNotReadable; @@ -176,7 +212,7 @@ pub const JsApi = struct { 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 @"error" = bridge.function(ReadableStreamDefaultController.doError, .{}); pub const desiredSize = bridge.accessor(ReadableStreamDefaultController.getDesiredSize, null, .{}); diff --git a/src/browser/webapi/streams/ReadableStreamDefaultReader.zig b/src/browser/webapi/streams/ReadableStreamDefaultReader.zig index 053080c4..2d3c5bbe 100644 --- a/src/browser/webapi/streams/ReadableStreamDefaultReader.zig +++ b/src/browser/webapi/streams/ReadableStreamDefaultReader.zig @@ -44,11 +44,13 @@ pub const ReadResult = struct { empty, string: []const u8, uint8array: js.TypedArray(u8), + js_value: js.Value.Global, pub fn fromChunk(chunk: ReadableStreamDefaultController.Chunk) Chunk { return switch (chunk) { .string => |s| .{ .string = s }, .uint8array => |arr| .{ .uint8array = arr }, + .js_value => |val| .{ .js_value = val }, }; } }; diff --git a/src/browser/webapi/streams/TransformStream.zig b/src/browser/webapi/streams/TransformStream.zig new file mode 100644 index 00000000..e1f42029 --- /dev/null +++ b/src/browser/webapi/streams/TransformStream.zig @@ -0,0 +1,198 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const ReadableStream = @import("ReadableStream.zig"); +const ReadableStreamDefaultController = @import("ReadableStreamDefaultController.zig"); +const WritableStream = @import("WritableStream.zig"); + +const TransformStream = @This(); + +pub const DefaultController = TransformStreamDefaultController; + +pub const ZigTransformFn = *const fn (*TransformStreamDefaultController, js.Value) anyerror!void; + +_readable: *ReadableStream, +_writable: *WritableStream, +_controller: *TransformStreamDefaultController, + +const Transformer = struct { + start: ?js.Function = null, + transform: ?js.Function.Global = null, + flush: ?js.Function.Global = null, +}; + +pub fn init(transformer_: ?Transformer, page: *Page) !*TransformStream { + const readable = try ReadableStream.init(null, null, page); + + const self = try page._factory.create(TransformStream{ + ._readable = readable, + ._writable = undefined, + ._controller = undefined, + }); + + const transform_controller = try TransformStreamDefaultController.init( + self, + if (transformer_) |t| t.transform else null, + if (transformer_) |t| t.flush else null, + null, + page, + ); + self._controller = transform_controller; + + self._writable = try WritableStream.initForTransform(self, page); + + if (transformer_) |transformer| { + if (transformer.start) |start| { + try start.call(void, .{transform_controller}); + } + } + + return self; +} + +pub fn initWithZigTransform(zig_transform: ZigTransformFn, page: *Page) !*TransformStream { + const readable = try ReadableStream.init(null, null, page); + + const self = try page._factory.create(TransformStream{ + ._readable = readable, + ._writable = undefined, + ._controller = undefined, + }); + + const transform_controller = try TransformStreamDefaultController.init(self, null, null, zig_transform, page); + self._controller = transform_controller; + + self._writable = try WritableStream.initForTransform(self, page); + + return self; +} + +pub fn transformWrite(self: *TransformStream, chunk: js.Value, page: *Page) !void { + if (self._controller._zig_transform_fn) |zig_fn| { + // Zig-level transform (used by TextEncoderStream etc.) + try zig_fn(self._controller, chunk); + return; + } + + if (self._controller._transform_fn) |transform_fn| { + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + try ls.toLocal(transform_fn).call(void, .{ chunk, self._controller }); + } else { + try self._readable._controller.enqueue(.{ .string = try chunk.toStringSlice() }); + } +} + +pub fn transformClose(self: *TransformStream, page: *Page) !void { + if (self._controller._flush_fn) |flush_fn| { + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + try ls.toLocal(flush_fn).call(void, .{self._controller}); + } + + try self._readable._controller.close(); +} + +pub fn getReadable(self: *const TransformStream) *ReadableStream { + return self._readable; +} + +pub fn getWritable(self: *const TransformStream) *WritableStream { + return self._writable; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TransformStream); + + pub const Meta = struct { + pub const name = "TransformStream"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(TransformStream.init, .{}); + pub const readable = bridge.accessor(TransformStream.getReadable, null, .{}); + pub const writable = bridge.accessor(TransformStream.getWritable, null, .{}); +}; + +pub fn registerTypes() []const type { + return &.{ + TransformStream, + TransformStreamDefaultController, + }; +} + +pub const TransformStreamDefaultController = struct { + _stream: *TransformStream, + _transform_fn: ?js.Function.Global, + _flush_fn: ?js.Function.Global, + _zig_transform_fn: ?ZigTransformFn, + + pub fn init( + stream: *TransformStream, + transform_fn: ?js.Function.Global, + flush_fn: ?js.Function.Global, + zig_transform_fn: ?ZigTransformFn, + page: *Page, + ) !*TransformStreamDefaultController { + return page._factory.create(TransformStreamDefaultController{ + ._stream = stream, + ._transform_fn = transform_fn, + ._flush_fn = flush_fn, + ._zig_transform_fn = zig_transform_fn, + }); + } + + pub fn enqueue(self: *TransformStreamDefaultController, chunk: ReadableStreamDefaultController.Chunk) !void { + 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 { + try self._stream._readable._controller.doError(reason); + } + + pub fn terminate(self: *TransformStreamDefaultController) !void { + try self._stream._readable._controller.close(); + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(TransformStreamDefaultController); + + pub const Meta = struct { + pub const name = "TransformStreamDefaultController"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const enqueue = bridge.function(TransformStreamDefaultController.enqueueValue, .{}); + pub const @"error" = bridge.function(TransformStreamDefaultController.doError, .{}); + pub const terminate = bridge.function(TransformStreamDefaultController.terminate, .{}); + }; +}; diff --git a/src/browser/webapi/streams/WritableStream.zig b/src/browser/webapi/streams/WritableStream.zig new file mode 100644 index 00000000..ae05ddd3 --- /dev/null +++ b/src/browser/webapi/streams/WritableStream.zig @@ -0,0 +1,156 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const WritableStreamDefaultWriter = @import("WritableStreamDefaultWriter.zig"); +const WritableStreamDefaultController = @import("WritableStreamDefaultController.zig"); +const TransformStream = @import("TransformStream.zig"); + +const WritableStream = @This(); + +pub const State = enum { + writable, + closed, + errored, +}; + +_state: State, +_writer: ?*WritableStreamDefaultWriter, +_controller: *WritableStreamDefaultController, +_stored_error: ?[]const u8, +_write_fn: ?js.Function.Global, +_close_fn: ?js.Function.Global, +_transform_stream: ?*TransformStream, + +const UnderlyingSink = struct { + start: ?js.Function = null, + write: ?js.Function.Global = null, + close: ?js.Function.Global = null, + abort: ?js.Function.Global = null, + type: ?[]const u8 = null, +}; + +pub fn init(sink_: ?UnderlyingSink, page: *Page) !*WritableStream { + const self = try page._factory.create(WritableStream{ + ._state = .writable, + ._writer = null, + ._controller = undefined, + ._stored_error = null, + ._write_fn = null, + ._close_fn = null, + ._transform_stream = null, + }); + + self._controller = try WritableStreamDefaultController.init(self, page); + + if (sink_) |sink| { + if (sink.start) |start| { + try start.call(void, .{self._controller}); + } + self._write_fn = sink.write; + self._close_fn = sink.close; + } + + return self; +} + +pub fn initForTransform(transform_stream: *TransformStream, page: *Page) !*WritableStream { + const self = try page._factory.create(WritableStream{ + ._state = .writable, + ._writer = null, + ._controller = undefined, + ._stored_error = null, + ._write_fn = null, + ._close_fn = null, + ._transform_stream = transform_stream, + }); + + self._controller = try WritableStreamDefaultController.init(self, page); + return self; +} + +pub fn getWriter(self: *WritableStream, page: *Page) !*WritableStreamDefaultWriter { + if (self.getLocked()) { + return error.WriterLocked; + } + + const writer = try WritableStreamDefaultWriter.init(self, page); + self._writer = writer; + return writer; +} + +pub fn getLocked(self: *const WritableStream) bool { + return self._writer != null; +} + +pub fn writeChunk(self: *WritableStream, chunk: js.Value, page: *Page) !void { + if (self._state != .writable) return; + + if (self._transform_stream) |ts| { + try ts.transformWrite(chunk, page); + return; + } + + if (self._write_fn) |write_fn| { + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + try ls.toLocal(write_fn).call(void, .{ chunk, self._controller }); + } +} + +pub fn closeStream(self: *WritableStream, page: *Page) !void { + if (self._state != .writable) return; + self._state = .closed; + + if (self._transform_stream) |ts| { + try ts.transformClose(page); + return; + } + + if (self._close_fn) |close_fn| { + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + try ls.toLocal(close_fn).call(void, .{self._controller}); + } +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(WritableStream); + + pub const Meta = struct { + pub const name = "WritableStream"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(WritableStream.init, .{}); + pub const getWriter = bridge.function(WritableStream.getWriter, .{}); + pub const locked = bridge.accessor(WritableStream.getLocked, null, .{}); +}; + +pub fn registerTypes() []const type { + return &.{ + WritableStream, + }; +} diff --git a/src/browser/webapi/streams/WritableStreamDefaultController.zig b/src/browser/webapi/streams/WritableStreamDefaultController.zig new file mode 100644 index 00000000..c5adfbae --- /dev/null +++ b/src/browser/webapi/streams/WritableStreamDefaultController.zig @@ -0,0 +1,49 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); +const WritableStream = @import("WritableStream.zig"); + +const WritableStreamDefaultController = @This(); + +_stream: *WritableStream, + +pub fn init(stream: *WritableStream, page: *Page) !*WritableStreamDefaultController { + return page._factory.create(WritableStreamDefaultController{ + ._stream = stream, + }); +} + +pub fn doError(self: *WritableStreamDefaultController, reason: []const u8) void { + if (self._stream._state != .writable) return; + self._stream._state = .errored; + self._stream._stored_error = reason; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(WritableStreamDefaultController); + + pub const Meta = struct { + pub const name = "WritableStreamDefaultController"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const @"error" = bridge.function(WritableStreamDefaultController.doError, .{}); +}; diff --git a/src/browser/webapi/streams/WritableStreamDefaultWriter.zig b/src/browser/webapi/streams/WritableStreamDefaultWriter.zig new file mode 100644 index 00000000..8ed552ea --- /dev/null +++ b/src/browser/webapi/streams/WritableStreamDefaultWriter.zig @@ -0,0 +1,109 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); +const WritableStream = @import("WritableStream.zig"); + +const WritableStreamDefaultWriter = @This(); + +_stream: ?*WritableStream, + +pub fn init(stream: *WritableStream, page: *Page) !*WritableStreamDefaultWriter { + return page._factory.create(WritableStreamDefaultWriter{ + ._stream = stream, + }); +} + +pub fn write(self: *WritableStreamDefaultWriter, chunk: js.Value, page: *Page) !js.Promise { + const stream = self._stream orelse { + return page.js.local.?.rejectPromise("Writer has been released"); + }; + + if (stream._state != .writable) { + return page.js.local.?.rejectPromise("Stream is not writable"); + } + + try stream.writeChunk(chunk, page); + + return page.js.local.?.resolvePromise(.{}); +} + +pub fn close(self: *WritableStreamDefaultWriter, page: *Page) !js.Promise { + const stream = self._stream orelse { + return page.js.local.?.rejectPromise("Writer has been released"); + }; + + if (stream._state != .writable) { + return page.js.local.?.rejectPromise("Stream is not writable"); + } + + try stream.closeStream(page); + + return page.js.local.?.resolvePromise(.{}); +} + +pub fn releaseLock(self: *WritableStreamDefaultWriter) void { + if (self._stream) |stream| { + stream._writer = null; + self._stream = null; + } +} + +pub fn getClosed(self: *WritableStreamDefaultWriter, page: *Page) !js.Promise { + const stream = self._stream orelse { + return page.js.local.?.rejectPromise("Writer has been released"); + }; + + if (stream._state == .closed) { + return page.js.local.?.resolvePromise(.{}); + } + + return page.js.local.?.resolvePromise(.{}); +} + +pub fn getDesiredSize(self: *const WritableStreamDefaultWriter) ?i32 { + const stream = self._stream orelse return null; + return switch (stream._state) { + .writable => 1, + .closed => 0, + .errored => null, + }; +} + +pub fn getReady(self: *WritableStreamDefaultWriter, page: *Page) !js.Promise { + _ = self; + return page.js.local.?.resolvePromise(.{}); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(WritableStreamDefaultWriter); + + pub const Meta = struct { + pub const name = "WritableStreamDefaultWriter"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const write = bridge.function(WritableStreamDefaultWriter.write, .{}); + pub const close = bridge.function(WritableStreamDefaultWriter.close, .{}); + pub const releaseLock = bridge.function(WritableStreamDefaultWriter.releaseLock, .{}); + pub const closed = bridge.accessor(WritableStreamDefaultWriter.getClosed, null, .{}); + pub const ready = bridge.accessor(WritableStreamDefaultWriter.getReady, null, .{}); + pub const desiredSize = bridge.accessor(WritableStreamDefaultWriter.getDesiredSize, null, .{}); +};