From 058a5a43ba0c170d8d8d08b1656edcfb6074beb6 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 18 Jul 2025 16:47:04 +0800 Subject: [PATCH] Add MessageChannel --- src/browser/dom/Animation.zig | 2 +- src/browser/dom/MessageChannel.zig | 361 +++++++++++++++++++++++++++++ src/browser/dom/dom.zig | 1 + src/browser/dom/event_target.zig | 4 + src/browser/events/event.zig | 4 +- src/browser/netsurf.zig | 2 + src/runtime/js.zig | 26 +-- 7 files changed, 383 insertions(+), 17 deletions(-) create mode 100644 src/browser/dom/MessageChannel.zig diff --git a/src/browser/dom/Animation.zig b/src/browser/dom/Animation.zig index d3d2503e..a638e1a8 100644 --- a/src/browser/dom/Animation.zig +++ b/src/browser/dom/Animation.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025s Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/browser/dom/MessageChannel.zig b/src/browser/dom/MessageChannel.zig new file mode 100644 index 00000000..5978cbf8 --- /dev/null +++ b/src/browser/dom/MessageChannel.zig @@ -0,0 +1,361 @@ +// 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 log = @import("../../log.zig"); +const parser = @import("../netsurf.zig"); + +const Env = @import("../env.zig").Env; +const Page = @import("../page.zig").Page; +const EventTarget = @import("../dom/event_target.zig").EventTarget; +const EventHandler = @import("../events/event.zig").EventHandler; + +const JsObject = Env.JsObject; +const Function = Env.Function; +const Allocator = std.mem.Allocator; + +const MAX_QUEUE_SIZE = 10; + +pub const Interfaces = .{ MessageChannel, MessagePort }; + +const MessageChannel = @This(); + +port1: *MessagePort, +port2: *MessagePort, + +pub fn constructor(page: *Page) !MessageChannel { + // Why do we allocate this rather than storing directly in the struct? + // https://github.com/lightpanda-io/project/discussions/165 + const port1 = try page.arena.create(MessagePort); + const port2 = try page.arena.create(MessagePort); + port1.* = .{ + .pair = port2, + }; + port2.* = .{ + .pair = port1, + }; + + return .{ + .port1 = port1, + .port2 = port2, + }; +} + +pub fn get_port1(self: *const MessageChannel) *MessagePort { + return self.port1; +} + +pub fn get_port2(self: *const MessageChannel) *MessagePort { + return self.port2; +} + +pub const MessagePort = struct { + pub const prototype = *EventTarget; + + proto: parser.EventTargetTBase = .{ .internal_target_type = .message_port }, + + pair: *MessagePort, + closed: bool = false, + started: bool = false, + onmessage_cbk: ?Function = null, + onmessageerror_cbk: ?Function = null, + // This is the queue of messages to dispatch to THIS MessagePort when the + // MessagePort is started. + queue: std.ArrayListUnmanaged(JsObject) = .empty, + + pub const PostMessageOption = union(enum) { + transfer: JsObject, + options: Opts, + + pub const Opts = struct { + transfer: JsObject, + }; + }; + + pub fn _postMessage(self: *MessagePort, obj: JsObject, opts_: ?PostMessageOption, page: *Page) !void { + if (self.closed) { + return; + } + + if (opts_ != null) { + log.warn(.web_api, "not implemented", .{ .feature = "MessagePort postMessage options" }); + return error.NotImplemented; + } + + try self.pair.dispatchOrQueue(obj, page.arena); + } + + // Start impacts the ability to receive a message. + // Given pair1 (started) and pair2 (not started), then: + // pair2.postMessage('x'); //will be dispatched to pair1.onmessage + // pair1.postMessage('x'); // will be queued until pair2 is started + pub fn _start(self: *MessagePort) !void { + if (self.started) { + return; + } + self.started = true; + for (self.queue.items) |data| { + try self.dispatch(data); + } + // we'll never use this queue again, but it's allocated with an arena + // we don't even need to clear it, but it seems a bit safer to do at + // least that + self.queue.clearRetainingCapacity(); + } + + // Closing seems to stop both the publishing and receiving of messages, + // effectively rendering the channel useless. It cannot be reversed. + pub fn _close(self: *MessagePort) void { + self.closed = true; + self.pair.closed = true; + } + + pub fn get_onmessage(self: *MessagePort) ?Function { + return self.onmessage_cbk; + } + pub fn get_onmessageerror(self: *MessagePort) ?Function { + return self.onmessageerror_cbk; + } + + pub fn set_onmessage(self: *MessagePort, listener: EventHandler.Listener, page: *Page) !void { + if (self.onmessage_cbk) |cbk| { + try self.unregister("message", cbk.id); + } + self.onmessage_cbk = try self.register(page.arena, "message", listener); + + // When onmessage is set directly, then it's like start() was called. + // If addEventListener('message') is used, the app has to call start() + // explicitly. + try self._start(); + } + + pub fn set_onmessageerror(self: *MessagePort, listener: EventHandler.Listener, page: *Page) !void { + if (self.onmessageerror_cbk) |cbk| { + try self.unregister("messageerror", cbk.id); + } + self.onmessageerror_cbk = try self.register(page.arena, "messageerror", listener); + } + + // called from our pair. If port1.postMessage("x") is called, then this + // will be called on port2. + fn dispatchOrQueue(self: *MessagePort, obj: JsObject, arena: Allocator) !void { + // our pair should have checked this already + std.debug.assert(self.closed == false); + + if (self.started) { + return self.dispatch(try obj.persist()); + } + + if (self.queue.items.len > MAX_QUEUE_SIZE) { + // This isn't part of the spec, but not putting a limit is reckless + return error.MessageQueueLimit; + } + return self.queue.append(arena, try obj.persist()); + } + + fn dispatch(self: *MessagePort, obj: JsObject) !void { + // obj is already persisted, don't use `MessageEvent.constructor`, but + // go directly to `init`, which assumes persisted objects. + var evt = try MessageEvent.init(.{ .data = obj }); + _ = try parser.eventTargetDispatchEvent( + parser.toEventTarget(MessagePort, self), + @as(*parser.Event, @ptrCast(&evt)), + ); + } + + fn register( + self: *MessagePort, + alloc: Allocator, + typ: []const u8, + listener: EventHandler.Listener, + ) !?Function { + const target = @as(*parser.EventTarget, @ptrCast(self)); + const eh = (try EventHandler.register(alloc, target, typ, listener, null)) orelse unreachable; + return eh.callback; + } + + fn unregister(self: *MessagePort, typ: []const u8, cbk_id: usize) !void { + const et = @as(*parser.EventTarget, @ptrCast(self)); + const lst = try parser.eventTargetHasListener(et, typ, false, cbk_id); + if (lst == null) { + return; + } + try parser.eventTargetRemoveEventListener(et, typ, lst.?, false); + } +}; + +pub const MessageEvent = struct { + const Event = @import("../events/event.zig").Event; + const DOMException = @import("exceptions.zig").DOMException; + + pub const prototype = *Event; + pub const Exception = DOMException; + pub const union_make_copy = true; + + proto: parser.Event, + data: ?JsObject, + + // You would think if port1 sends to port2, the source would be port2 + // (which is how I read the documentation), but it appears to always be + // null. It can always be set explicitly via the constructor; + source: ?JsObject, + + origin: []const u8, + + // This is used for Server-Sent events. Appears to always be an empty + // string for MessagePort messages. + last_event_id: []const u8, + + // This might be related to the "transfer" option of postMessage which + // we don't yet support. For "normal" message, it's always an empty array. + // Though it could be set explicitly via the constructor + ports: []*MessagePort, + + const Options = struct { + data: ?JsObject = null, + source: ?JsObject = null, + origin: []const u8 = "", + lastEventId: []const u8 = "", + ports: []*MessagePort = &.{}, + }; + + pub fn constructor(opts: Options) !MessageEvent { + return init(.{ + .data = if (opts.data) |obj| try obj.persist() else null, + .source = if (opts.source) |obj| try obj.persist() else null, + .ports = opts.ports, + .origin = opts.origin, + .lastEventId = opts.lastEventId, + }); + } + + // This is like "constructor", but it assumes JsObjects have already been + // persisted. Necessary because this `new MessageEvent()` can be called + // directly from JS OR from a port.postMessage. In the latter case, data + // may have already been persisted (as it might need to be queued); + fn init(opts: Options) !MessageEvent { + const event = try parser.eventCreate(); + defer parser.eventDestroy(event); + try parser.eventInit(event, "message", .{}); + try parser.eventSetInternalType(event, .message_event); + + return .{ + .proto = event.*, + .data = opts.data, + .source = opts.source, + .ports = opts.ports, + .origin = opts.origin, + .last_event_id = opts.lastEventId, + }; + } + + pub fn get_data(self: *const MessageEvent) !?JsObject { + return self.data; + } + + pub fn get_origin(self: *const MessageEvent) []const u8 { + return self.origin; + } + + pub fn get_source(self: *const MessageEvent) ?JsObject { + return self.source; + } + + pub fn get_ports(self: *const MessageEvent) []*MessagePort { + return self.ports; + } + + pub fn get_lastEventId(self: *const MessageEvent) []const u8 { + return self.last_event_id; + } +}; + +const testing = @import("../../testing.zig"); +test "Browser.MessageChannel" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{ + .html = "", + }); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "const mc1 = new MessageChannel()", null }, + .{ "mc1.port1 == mc1.port1", "true" }, + .{ "mc1.port2 == mc1.port2", "true" }, + .{ "mc1.port1 != mc1.port2", "true" }, + .{ "mc1.port1.postMessage('msg1');", "undefined" }, + .{ + \\ let message = null; + \\ let target = null; + \\ let currentTarget = null; + \\ mc1.port2.onmessage = (e) => { + \\ message = e.data; + \\ target = e.target; + \\ currentTarget = e.currentTarget; + \\ }; + , + null, + }, + // as soon as onmessage is called, queued messages are delivered + .{ "message", "msg1" }, + .{ "target == mc1.port2", "true" }, + .{ "currentTarget == mc1.port2", "true" }, + + .{ "mc1.port1.postMessage('msg2');", "undefined" }, + .{ "message", "msg2" }, + .{ "target == mc1.port2", "true" }, + .{ "currentTarget == mc1.port2", "true" }, + + .{ "message = null", null }, + .{ "mc1.port1.close();", null }, + .{ "mc1.port1.postMessage('msg3');", "undefined" }, + .{ "message", "null" }, + }, .{}); + + try runner.testCases(&.{ + .{ "const mc2 = new MessageChannel()", null }, + .{ "mc2.port2.postMessage('msg1');", "undefined" }, + .{ "mc2.port1.postMessage('msg2');", "undefined" }, + .{ + \\ let message1 = null; + \\ mc2.port1.addEventListener('message', (e) => { + \\ message1 = e.data; + \\ }); + , + null, + }, + .{ + \\ let message2 = null; + \\ mc2.port2.addEventListener('message', (e) => { + \\ message2 = e.data; + \\ }); + , + null, + }, + .{ "message1", "null" }, + .{ "message2", "null" }, + .{ "mc2.port2.start()", null }, + + .{ "message1", "null" }, + .{ "message2", "msg2" }, + .{ "message2 = null", null }, + + .{ "mc2.port1.start()", null }, + .{ "message1", "msg1" }, + .{ "message2", "null" }, + }, .{}); +} diff --git a/src/browser/dom/dom.zig b/src/browser/dom/dom.zig index 2ce677a8..c73ac47f 100644 --- a/src/browser/dom/dom.zig +++ b/src/browser/dom/dom.zig @@ -53,4 +53,5 @@ pub const Interfaces = .{ PerformanceObserver, @import("range.zig").Interfaces, @import("Animation.zig"), + @import("MessageChannel.zig").Interfaces, }; diff --git a/src/browser/dom/event_target.zig b/src/browser/dom/event_target.zig index bf59910a..e9c2cfb2 100644 --- a/src/browser/dom/event_target.zig +++ b/src/browser/dom/event_target.zig @@ -30,6 +30,7 @@ pub const Union = union(enum) { node: nod.Union, xhr: *@import("../xhr/xhr.zig").XMLHttpRequest, plain: *parser.EventTarget, + message_port: *@import("MessageChannel.zig").MessagePort, }; // EventTarget implementation @@ -63,6 +64,9 @@ pub const EventTarget = struct { const base: *XMLHttpRequestEventTarget = @fieldParentPtr("base", @as(*parser.EventTargetTBase, @ptrCast(et))); return .{ .xhr = @fieldParentPtr("proto", base) }; }, + .message_port => { + return .{ .message_port = @fieldParentPtr("proto", @as(*parser.EventTargetTBase, @ptrCast(et))) }; + }, else => return error.MissingEventTargetType, } } diff --git a/src/browser/events/event.zig b/src/browser/events/event.zig index b9a5c297..4a524be0 100644 --- a/src/browser/events/event.zig +++ b/src/browser/events/event.zig @@ -33,9 +33,10 @@ const CustomEvent = @import("custom_event.zig").CustomEvent; const ProgressEvent = @import("../xhr/progress_event.zig").ProgressEvent; const MouseEvent = @import("mouse_event.zig").MouseEvent; const ErrorEvent = @import("../html/error_event.zig").ErrorEvent; +const MessageEvent = @import("../dom/MessageChannel.zig").MessageEvent; // Event interfaces -pub const Interfaces = .{ Event, CustomEvent, ProgressEvent, MouseEvent, ErrorEvent }; +pub const Interfaces = .{ Event, CustomEvent, ProgressEvent, MouseEvent, ErrorEvent, MessageEvent }; pub const Union = generate.Union(Interfaces); @@ -60,6 +61,7 @@ pub const Event = struct { .progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* }, .mouse_event => .{ .MouseEvent = @as(*parser.MouseEvent, @ptrCast(evt)) }, .error_event => .{ .ErrorEvent = @as(*ErrorEvent, @ptrCast(evt)).* }, + .message_event => .{ .MessageEvent = @as(*MessageEvent, @ptrCast(evt)).* }, }; } diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig index f0af5280..c14e0b48 100644 --- a/src/browser/netsurf.zig +++ b/src/browser/netsurf.zig @@ -528,6 +528,7 @@ pub const EventType = enum(u8) { error_event = 4, abort_signal = 5, xhr_event = 6, + message_event = 7, }; pub const MutationEvent = c.dom_mutation_event; @@ -786,6 +787,7 @@ pub const EventTargetTBase = extern struct { window = 4, performance = 5, media_query_list = 6, + message_port = 7, }; vtable: ?*const c.struct_dom_event_target_vtable = &c.struct_dom_event_target_vtable{ diff --git a/src/runtime/js.zig b/src/runtime/js.zig index b5216ab8..a67e278e 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -634,13 +634,15 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { // The key is the @intFromPtr of the Zig value identity_map: std.AutoHashMapUnmanaged(usize, PersistentObject) = .empty, - // Similar to the identity map, but used much less frequently. Some - // web APIs have to manage opaque values. Ideally, they use an + // Some web APIs have to manage opaque values. Ideally, they use an // JsObject, but the JsObject has no lifetime guarantee beyond the // current call. They can call .persist() on their JsObject to get // a `*PersistentObject()`. We need to track these to free them. - // The key is the @intFromPtr of the v8.Object.handle. - js_object_map: std.AutoHashMapUnmanaged(usize, PersistentObject) = .empty, + // This used to be a map and acted like identity_map; the key was + // the @intFromPtr(js_obj.handle). But v8 can re-use address. Without + // a reliable way to know if an object has already been persisted, + // we now simply persist every time persist() is called. + js_object_list: std.ArrayListUnmanaged(PersistentObject) = .empty, // When we need to load a resource (i.e. an external script), we call // this function to get the source. This is always a reference to the @@ -690,11 +692,8 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { } } - { - var it = self.js_object_map.valueIterator(); - while (it.next()) |p| { - p.deinit(); - } + for (self.js_object_list.items) |*p| { + p.deinit(); } { @@ -1883,16 +1882,13 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { pub fn persist(self: JsObject) !JsObject { var js_context = self.js_context; const js_obj = self.js_obj; - const handle = js_obj.handle; - const gop = try js_context.js_object_map.getOrPut(js_context.context_arena, @intFromPtr(handle)); - if (gop.found_existing == false) { - gop.value_ptr.* = PersistentObject.init(js_context.isolate, js_obj); - } + const persisted = PersistentObject.init(js_context.isolate, js_obj); + try js_context.js_object_list.append(js_context.context_arena, persisted); return .{ .js_context = js_context, - .js_obj = gop.value_ptr.castToObject(), + .js_obj = persisted.castToObject(), }; }