From 9f587ab24b0632e3b488d65ee97668cd0c787e97 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 28 Nov 2025 22:11:55 +0800 Subject: [PATCH] MessageChannel and MessagePort --- src/browser/EventManager.zig | 2 +- src/browser/Page.zig | 3 + src/browser/Scheduler.zig | 11 +- src/browser/ScriptManager.zig | 4 + src/browser/js/bridge.zig | 2 + src/browser/tests/message_channel.html | 86 +++++++++++++ src/browser/webapi/EventTarget.zig | 2 + src/browser/webapi/MessageChannel.zig | 66 ++++++++++ src/browser/webapi/MessagePort.zig | 169 +++++++++++++++++++++++++ 9 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 src/browser/tests/message_channel.html create mode 100644 src/browser/webapi/MessageChannel.zig create mode 100644 src/browser/webapi/MessagePort.zig diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 3eb02bae..a138c44f 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -117,7 +117,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void switch (target._type) { .node => |node| try self.dispatchNode(node, event, &was_handled), - .xhr, .window, .abort_signal, .media_query_list => { + .xhr, .window, .abort_signal, .media_query_list, .message_port => { const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; try self.dispatchAll(list, target, event, &was_handled); }, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 37d947c4..f237c315 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -702,6 +702,9 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { } pub fn tick(self: *Page) void { + if (comptime IS_DEBUG) { + log.debug(.page, "tick", .{}); + } _ = self.scheduler.run() catch |err| { log.err(.page, "tick", .{ .err = err }); }; diff --git a/src/browser/Scheduler.zig b/src/browser/Scheduler.zig index 6ad04887..78a7ca1e 100644 --- a/src/browser/Scheduler.zig +++ b/src/browser/Scheduler.zig @@ -26,17 +26,22 @@ const IS_DEBUG = builtin.mode == .Debug; const Queue = std.PriorityQueue(Task, void, struct { fn compare(_: void, a: Task, b: Task) std.math.Order { - return std.math.order(a.run_at, b.run_at); + const time_order = std.math.order(a.run_at, b.run_at); + if (time_order != .eq) return time_order; + // Break ties with sequence number to maintain FIFO order + return std.math.order(a.sequence, b.sequence); } }.compare); const Scheduler = @This(); +_sequence: u64, low_priority: Queue, high_priority: Queue, pub fn init(allocator: std.mem.Allocator) Scheduler { return .{ + ._sequence = 0, .low_priority = Queue.init(allocator, {}), .high_priority = Queue.init(allocator, {}), }; @@ -59,9 +64,12 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts log.debug(.scheduler, "scheduler.add", .{ .name = opts.name, .run_in_ms = run_in_ms, .low_priority = opts.low_priority }); } var queue = if (opts.low_priority) &self.low_priority else &self.high_priority; + const seq = self._sequence + 1; + self._sequence = seq; return queue.add(.{ .ctx = ctx, .callback = cb, + .sequence = seq, .name = opts.name, .run_at = timestamp(.monotonic) + run_in_ms, }); @@ -105,6 +113,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 { const Task = struct { run_at: u64, + sequence: u64, ctx: *anyopaque, name: []const u8, callback: Callback, diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index a1df242e..ca098e9f 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -751,6 +751,10 @@ const Script = struct { break :blk true; }; + if (comptime IS_DEBUG) { + log.info(.browser, "executed script", .{.src = url}); + } + defer page.tick(); if (success) { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 68f5e489..d3d983d4 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -552,6 +552,8 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/ErrorEvent.zig"), @import("../webapi/event/MessageEvent.zig"), @import("../webapi/event/ProgressEvent.zig"), + @import("../webapi/MessageChannel.zig"), + @import("../webapi/MessagePort.zig"), @import("../webapi/EventTarget.zig"), @import("../webapi/Location.zig"), @import("../webapi/Navigator.zig"), diff --git a/src/browser/tests/message_channel.html b/src/browser/tests/message_channel.html new file mode 100644 index 00000000..0a9848f7 --- /dev/null +++ b/src/browser/tests/message_channel.html @@ -0,0 +1,86 @@ + + + + diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 23ecdf98..e0d0acd1 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -34,6 +34,7 @@ pub const Type = union(enum) { xhr: *@import("net/XMLHttpRequestEventTarget.zig"), abort_signal: *@import("AbortSignal.zig"), media_query_list: *@import("css/MediaQueryList.zig"), + message_port: *@import("MessagePort.zig"), }; pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { @@ -101,6 +102,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .xhr => writer.writeAll(""), .abort_signal => writer.writeAll(""), .media_query_list => writer.writeAll(""), + .message_port => writer.writeAll(""), }; } diff --git a/src/browser/webapi/MessageChannel.zig b/src/browser/webapi/MessageChannel.zig new file mode 100644 index 00000000..76631013 --- /dev/null +++ b/src/browser/webapi/MessageChannel.zig @@ -0,0 +1,66 @@ +// Copyright (C) 2023-2025 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 MessagePort = @import("MessagePort.zig"); + +const MessageChannel = @This(); + +_port1: *MessagePort, +_port2: *MessagePort, + +pub fn init(page: *Page) !*MessageChannel { + const port1 = try MessagePort.init(page); + const port2 = try MessagePort.init(page); + + MessagePort.entangle(port1, port2); + + return page._factory.create(MessageChannel{ + ._port1 = port1, + ._port2 = port2, + }); +} + + +pub fn getPort1(self: *const MessageChannel) *MessagePort { + return self._port1; +} + +pub fn getPort2(self: *const MessageChannel) *MessagePort { + return self._port2; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(MessageChannel); + + pub const Meta = struct { + pub const name = "MessageChannel"; + pub var class_id: bridge.ClassId = undefined; + pub const prototype_chain = bridge.prototypeChain(); + }; + + pub const constructor = bridge.constructor(MessageChannel.init, .{}); + pub const port1 = bridge.accessor(MessageChannel.getPort1, null, .{}); + pub const port2 = bridge.accessor(MessageChannel.getPort2, null, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: MessageChannel" { + try testing.htmlRunner("message_channel.html", .{}); +} diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig new file mode 100644 index 00000000..65a7d36b --- /dev/null +++ b/src/browser/webapi/MessagePort.zig @@ -0,0 +1,169 @@ +// Copyright (C) 2023-2025 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 log = @import("../../log.zig"); + +const Page = @import("../Page.zig"); +const EventTarget = @import("EventTarget.zig"); +const MessageEvent = @import("event/MessageEvent.zig"); + +const MessagePort = @This(); + +_proto: *EventTarget, +_enabled: bool = false, +_closed: bool = false, +_on_message: ?js.Function = null, +_on_message_error: ?js.Function = null, +_entangled_port: ?*MessagePort = null, + +pub fn init(page: *Page) !*MessagePort { + return page._factory.eventTarget(MessagePort{ + ._proto = undefined, + }); +} + +pub fn asEventTarget(self: *MessagePort) *EventTarget { + return self._proto; +} + +pub fn entangle(port1: *MessagePort, port2: *MessagePort) void { + port1._entangled_port = port2; + port2._entangled_port = port1; +} + +pub fn postMessage(self: *MessagePort, message: js.Object, page: *Page) !void { + if (self._closed) { + return; + } + + const other = self._entangled_port orelse return; + if (other._closed) { + return; + } + + // Create callback to deliver message + const callback = try page._factory.create(PostMessageCallback{ + .page = page, + .port = other, + .message = try message.persist(), + }); + + try page.scheduler.add(callback, PostMessageCallback.run, 0, .{ + .name = "MessagePort.postMessage", + .low_priority = false, + }); +} + +pub fn start(self: *MessagePort) void { + if (self._closed) { + return; + } + self._enabled = true; +} + +pub fn close(self: *MessagePort) void { + self._closed = true; + + // Break entanglement + if (self._entangled_port) |other| { + other._entangled_port = null; + } + self._entangled_port = null; +} + +pub fn getOnMessage(self: *const MessagePort) ?js.Function { + return self._on_message; +} + +pub fn setOnMessage(self: *MessagePort, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_message = cb; + } else { + self._on_message = null; + } +} + +pub fn getOnMessageError(self: *const MessagePort) ?js.Function { + return self._on_message_error; +} + +pub fn setOnMessageError(self: *MessagePort, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_message_error = cb; + } else { + self._on_message_error = null; + } +} + +const PostMessageCallback = struct { + port: *MessagePort, + message: js.Object, + page: *Page, + + fn deinit(self: *PostMessageCallback) void { + self.page._factory.destroy(self); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); + defer self.deinit(); + + if (self.port._closed) { + return null; + } + + const event = MessageEvent.init("message", .{ + .data = self.message, + .origin = "", + .source = null, + }, self.page) catch |err| { + log.err(.dom, "MessagePort.postMessage", .{.err = err}); + return null; + }; + + self.page._event_manager.dispatchWithFunction( + self.port.asEventTarget(), + event.asEvent(), + self.port._on_message, + .{ .context = "MessagePort message" }, + ) catch |err| { + log.err(.dom, "MessagePort.postMessage", .{.err = err}); + }; + + return null; + } +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(MessagePort); + + pub const Meta = struct { + pub const name = "MessagePort"; + pub var class_id: bridge.ClassId = undefined; + pub const prototype_chain = bridge.prototypeChain(); + }; + + pub const postMessage = bridge.function(MessagePort.postMessage, .{}); + pub const start = bridge.function(MessagePort.start, .{}); + pub const close = bridge.function(MessagePort.close, .{}); + + pub const onmessage = bridge.accessor(MessagePort.getOnMessage, MessagePort.setOnMessage, .{}); + pub const onmessageerror = bridge.accessor(MessagePort.getOnMessageError, MessagePort.setOnMessageError, .{}); +};