From 5d3b965d285ad41f89fa33b781f9c2f3eef85ab1 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 2 Mar 2026 11:41:52 +0100 Subject: [PATCH] Implement WritableStream, TransformStream, and TextEncoderStream Add the missing Streams API types needed for TextEncoderStream support: - WritableStream with locked/getWriter, supporting both JS sink callbacks and internal TransformStream routing - WritableStreamDefaultWriter with write/close/releaseLock/closed/ready - WritableStreamDefaultController with error() - TransformStream with readable/writable accessors, JS transformer callbacks (start/transform/flush), and Zig-level transform support - TransformStreamDefaultController with enqueue/error/terminate - TextEncoderStream that encodes string chunks to UTF-8 Uint8Array via a Zig-level transform function --- src/browser/js/bridge.zig | 5 + .../tests/streams/transform_stream.html | 83 +++++++ .../webapi/encoding/TextEncoderStream.zig | 70 ++++++ .../webapi/streams/TransformStream.zig | 202 ++++++++++++++++++ src/browser/webapi/streams/WritableStream.zig | 159 ++++++++++++++ .../WritableStreamDefaultController.zig | 51 +++++ .../streams/WritableStreamDefaultWriter.zig | 101 +++++++++ 7 files changed, 671 insertions(+) create mode 100644 src/browser/tests/streams/transform_stream.html create mode 100644 src/browser/webapi/encoding/TextEncoderStream.zig create mode 100644 src/browser/webapi/streams/TransformStream.zig create mode 100644 src/browser/webapi/streams/WritableStream.zig create mode 100644 src/browser/webapi/streams/WritableStreamDefaultController.zig create mode 100644 src/browser/webapi/streams/WritableStreamDefaultWriter.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 8d1daba2..932c3655 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -826,6 +826,7 @@ 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/Event.zig"), @import("../webapi/event/CompositionEvent.zig"), @import("../webapi/event/CustomEvent.zig"), @@ -862,6 +863,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/transform_stream.html b/src/browser/tests/streams/transform_stream.html new file mode 100644 index 00000000..431a25ec --- /dev/null +++ b/src/browser/tests/streams/transform_stream.html @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + 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/TransformStream.zig b/src/browser/webapi/streams/TransformStream.zig new file mode 100644 index 00000000..6e51515d --- /dev/null +++ b/src/browser/webapi/streams/TransformStream.zig @@ -0,0 +1,202 @@ +// 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; + +_page: *Page, +_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{ + ._page = page, + ._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{ + ._page = page, + ._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 { + // Default transform: pass through + if (chunk.isString()) |str| { + const slice = try str.toSlice(); + try self._readable._controller.enqueue(.{ .string = slice }); + } + } +} + +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 { + _page: *Page, + _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{ + ._page = page, + ._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); + } + + 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.enqueue, .{}); + 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..8d1dd200 --- /dev/null +++ b/src/browser/webapi/streams/WritableStream.zig @@ -0,0 +1,159 @@ +// 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, +}; + +_page: *Page, +_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{ + ._page = page, + ._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{ + ._page = page, + ._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..06ca7503 --- /dev/null +++ b/src/browser/webapi/streams/WritableStreamDefaultController.zig @@ -0,0 +1,51 @@ +// 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(); + +_page: *Page, +_stream: *WritableStream, + +pub fn init(stream: *WritableStream, page: *Page) !*WritableStreamDefaultController { + return page._factory.create(WritableStreamDefaultController{ + ._page = page, + ._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..1139f54b --- /dev/null +++ b/src/browser/webapi/streams/WritableStreamDefaultWriter.zig @@ -0,0 +1,101 @@ +// 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(); + +_page: *Page, +_stream: ?*WritableStream, + +pub fn init(stream: *WritableStream, page: *Page) !*WritableStreamDefaultWriter { + return page._factory.create(WritableStreamDefaultWriter{ + ._page = page, + ._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 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, .{}); +};