Skip to content

Commit 058a5a4

Browse files
committed
Add MessageChannel
1 parent 878dbd8 commit 058a5a4

File tree

7 files changed

+383
-17
lines changed

7 files changed

+383
-17
lines changed

src/browser/dom/Animation.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (C) 2023-2025s Lightpanda (Selecy SAS)
1+
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
22
//
33
// Francis Bouvier <[email protected]>
44
// Pierre Tachoire <[email protected]>

src/browser/dom/MessageChannel.zig

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
2+
//
3+
// Francis Bouvier <[email protected]>
4+
// Pierre Tachoire <[email protected]>
5+
//
6+
// This program is free software: you can redistribute it and/or modify
7+
// it under the terms of the GNU Affero General Public License as
8+
// published by the Free Software Foundation, either version 3 of the
9+
// License, or (at your option) any later version.
10+
//
11+
// This program is distributed in the hope that it will be useful,
12+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
// GNU Affero General Public License for more details.
15+
//
16+
// You should have received a copy of the GNU Affero General Public License
17+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
19+
const std = @import("std");
20+
const log = @import("../../log.zig");
21+
const parser = @import("../netsurf.zig");
22+
23+
const Env = @import("../env.zig").Env;
24+
const Page = @import("../page.zig").Page;
25+
const EventTarget = @import("../dom/event_target.zig").EventTarget;
26+
const EventHandler = @import("../events/event.zig").EventHandler;
27+
28+
const JsObject = Env.JsObject;
29+
const Function = Env.Function;
30+
const Allocator = std.mem.Allocator;
31+
32+
const MAX_QUEUE_SIZE = 10;
33+
34+
pub const Interfaces = .{ MessageChannel, MessagePort };
35+
36+
const MessageChannel = @This();
37+
38+
port1: *MessagePort,
39+
port2: *MessagePort,
40+
41+
pub fn constructor(page: *Page) !MessageChannel {
42+
// Why do we allocate this rather than storing directly in the struct?
43+
// https://github.com/lightpanda-io/project/discussions/165
44+
const port1 = try page.arena.create(MessagePort);
45+
const port2 = try page.arena.create(MessagePort);
46+
port1.* = .{
47+
.pair = port2,
48+
};
49+
port2.* = .{
50+
.pair = port1,
51+
};
52+
53+
return .{
54+
.port1 = port1,
55+
.port2 = port2,
56+
};
57+
}
58+
59+
pub fn get_port1(self: *const MessageChannel) *MessagePort {
60+
return self.port1;
61+
}
62+
63+
pub fn get_port2(self: *const MessageChannel) *MessagePort {
64+
return self.port2;
65+
}
66+
67+
pub const MessagePort = struct {
68+
pub const prototype = *EventTarget;
69+
70+
proto: parser.EventTargetTBase = .{ .internal_target_type = .message_port },
71+
72+
pair: *MessagePort,
73+
closed: bool = false,
74+
started: bool = false,
75+
onmessage_cbk: ?Function = null,
76+
onmessageerror_cbk: ?Function = null,
77+
// This is the queue of messages to dispatch to THIS MessagePort when the
78+
// MessagePort is started.
79+
queue: std.ArrayListUnmanaged(JsObject) = .empty,
80+
81+
pub const PostMessageOption = union(enum) {
82+
transfer: JsObject,
83+
options: Opts,
84+
85+
pub const Opts = struct {
86+
transfer: JsObject,
87+
};
88+
};
89+
90+
pub fn _postMessage(self: *MessagePort, obj: JsObject, opts_: ?PostMessageOption, page: *Page) !void {
91+
if (self.closed) {
92+
return;
93+
}
94+
95+
if (opts_ != null) {
96+
log.warn(.web_api, "not implemented", .{ .feature = "MessagePort postMessage options" });
97+
return error.NotImplemented;
98+
}
99+
100+
try self.pair.dispatchOrQueue(obj, page.arena);
101+
}
102+
103+
// Start impacts the ability to receive a message.
104+
// Given pair1 (started) and pair2 (not started), then:
105+
// pair2.postMessage('x'); //will be dispatched to pair1.onmessage
106+
// pair1.postMessage('x'); // will be queued until pair2 is started
107+
pub fn _start(self: *MessagePort) !void {
108+
if (self.started) {
109+
return;
110+
}
111+
self.started = true;
112+
for (self.queue.items) |data| {
113+
try self.dispatch(data);
114+
}
115+
// we'll never use this queue again, but it's allocated with an arena
116+
// we don't even need to clear it, but it seems a bit safer to do at
117+
// least that
118+
self.queue.clearRetainingCapacity();
119+
}
120+
121+
// Closing seems to stop both the publishing and receiving of messages,
122+
// effectively rendering the channel useless. It cannot be reversed.
123+
pub fn _close(self: *MessagePort) void {
124+
self.closed = true;
125+
self.pair.closed = true;
126+
}
127+
128+
pub fn get_onmessage(self: *MessagePort) ?Function {
129+
return self.onmessage_cbk;
130+
}
131+
pub fn get_onmessageerror(self: *MessagePort) ?Function {
132+
return self.onmessageerror_cbk;
133+
}
134+
135+
pub fn set_onmessage(self: *MessagePort, listener: EventHandler.Listener, page: *Page) !void {
136+
if (self.onmessage_cbk) |cbk| {
137+
try self.unregister("message", cbk.id);
138+
}
139+
self.onmessage_cbk = try self.register(page.arena, "message", listener);
140+
141+
// When onmessage is set directly, then it's like start() was called.
142+
// If addEventListener('message') is used, the app has to call start()
143+
// explicitly.
144+
try self._start();
145+
}
146+
147+
pub fn set_onmessageerror(self: *MessagePort, listener: EventHandler.Listener, page: *Page) !void {
148+
if (self.onmessageerror_cbk) |cbk| {
149+
try self.unregister("messageerror", cbk.id);
150+
}
151+
self.onmessageerror_cbk = try self.register(page.arena, "messageerror", listener);
152+
}
153+
154+
// called from our pair. If port1.postMessage("x") is called, then this
155+
// will be called on port2.
156+
fn dispatchOrQueue(self: *MessagePort, obj: JsObject, arena: Allocator) !void {
157+
// our pair should have checked this already
158+
std.debug.assert(self.closed == false);
159+
160+
if (self.started) {
161+
return self.dispatch(try obj.persist());
162+
}
163+
164+
if (self.queue.items.len > MAX_QUEUE_SIZE) {
165+
// This isn't part of the spec, but not putting a limit is reckless
166+
return error.MessageQueueLimit;
167+
}
168+
return self.queue.append(arena, try obj.persist());
169+
}
170+
171+
fn dispatch(self: *MessagePort, obj: JsObject) !void {
172+
// obj is already persisted, don't use `MessageEvent.constructor`, but
173+
// go directly to `init`, which assumes persisted objects.
174+
var evt = try MessageEvent.init(.{ .data = obj });
175+
_ = try parser.eventTargetDispatchEvent(
176+
parser.toEventTarget(MessagePort, self),
177+
@as(*parser.Event, @ptrCast(&evt)),
178+
);
179+
}
180+
181+
fn register(
182+
self: *MessagePort,
183+
alloc: Allocator,
184+
typ: []const u8,
185+
listener: EventHandler.Listener,
186+
) !?Function {
187+
const target = @as(*parser.EventTarget, @ptrCast(self));
188+
const eh = (try EventHandler.register(alloc, target, typ, listener, null)) orelse unreachable;
189+
return eh.callback;
190+
}
191+
192+
fn unregister(self: *MessagePort, typ: []const u8, cbk_id: usize) !void {
193+
const et = @as(*parser.EventTarget, @ptrCast(self));
194+
const lst = try parser.eventTargetHasListener(et, typ, false, cbk_id);
195+
if (lst == null) {
196+
return;
197+
}
198+
try parser.eventTargetRemoveEventListener(et, typ, lst.?, false);
199+
}
200+
};
201+
202+
pub const MessageEvent = struct {
203+
const Event = @import("../events/event.zig").Event;
204+
const DOMException = @import("exceptions.zig").DOMException;
205+
206+
pub const prototype = *Event;
207+
pub const Exception = DOMException;
208+
pub const union_make_copy = true;
209+
210+
proto: parser.Event,
211+
data: ?JsObject,
212+
213+
// You would think if port1 sends to port2, the source would be port2
214+
// (which is how I read the documentation), but it appears to always be
215+
// null. It can always be set explicitly via the constructor;
216+
source: ?JsObject,
217+
218+
origin: []const u8,
219+
220+
// This is used for Server-Sent events. Appears to always be an empty
221+
// string for MessagePort messages.
222+
last_event_id: []const u8,
223+
224+
// This might be related to the "transfer" option of postMessage which
225+
// we don't yet support. For "normal" message, it's always an empty array.
226+
// Though it could be set explicitly via the constructor
227+
ports: []*MessagePort,
228+
229+
const Options = struct {
230+
data: ?JsObject = null,
231+
source: ?JsObject = null,
232+
origin: []const u8 = "",
233+
lastEventId: []const u8 = "",
234+
ports: []*MessagePort = &.{},
235+
};
236+
237+
pub fn constructor(opts: Options) !MessageEvent {
238+
return init(.{
239+
.data = if (opts.data) |obj| try obj.persist() else null,
240+
.source = if (opts.source) |obj| try obj.persist() else null,
241+
.ports = opts.ports,
242+
.origin = opts.origin,
243+
.lastEventId = opts.lastEventId,
244+
});
245+
}
246+
247+
// This is like "constructor", but it assumes JsObjects have already been
248+
// persisted. Necessary because this `new MessageEvent()` can be called
249+
// directly from JS OR from a port.postMessage. In the latter case, data
250+
// may have already been persisted (as it might need to be queued);
251+
fn init(opts: Options) !MessageEvent {
252+
const event = try parser.eventCreate();
253+
defer parser.eventDestroy(event);
254+
try parser.eventInit(event, "message", .{});
255+
try parser.eventSetInternalType(event, .message_event);
256+
257+
return .{
258+
.proto = event.*,
259+
.data = opts.data,
260+
.source = opts.source,
261+
.ports = opts.ports,
262+
.origin = opts.origin,
263+
.last_event_id = opts.lastEventId,
264+
};
265+
}
266+
267+
pub fn get_data(self: *const MessageEvent) !?JsObject {
268+
return self.data;
269+
}
270+
271+
pub fn get_origin(self: *const MessageEvent) []const u8 {
272+
return self.origin;
273+
}
274+
275+
pub fn get_source(self: *const MessageEvent) ?JsObject {
276+
return self.source;
277+
}
278+
279+
pub fn get_ports(self: *const MessageEvent) []*MessagePort {
280+
return self.ports;
281+
}
282+
283+
pub fn get_lastEventId(self: *const MessageEvent) []const u8 {
284+
return self.last_event_id;
285+
}
286+
};
287+
288+
const testing = @import("../../testing.zig");
289+
test "Browser.MessageChannel" {
290+
var runner = try testing.jsRunner(testing.tracking_allocator, .{
291+
.html = "",
292+
});
293+
defer runner.deinit();
294+
295+
try runner.testCases(&.{
296+
.{ "const mc1 = new MessageChannel()", null },
297+
.{ "mc1.port1 == mc1.port1", "true" },
298+
.{ "mc1.port2 == mc1.port2", "true" },
299+
.{ "mc1.port1 != mc1.port2", "true" },
300+
.{ "mc1.port1.postMessage('msg1');", "undefined" },
301+
.{
302+
\\ let message = null;
303+
\\ let target = null;
304+
\\ let currentTarget = null;
305+
\\ mc1.port2.onmessage = (e) => {
306+
\\ message = e.data;
307+
\\ target = e.target;
308+
\\ currentTarget = e.currentTarget;
309+
\\ };
310+
,
311+
null,
312+
},
313+
// as soon as onmessage is called, queued messages are delivered
314+
.{ "message", "msg1" },
315+
.{ "target == mc1.port2", "true" },
316+
.{ "currentTarget == mc1.port2", "true" },
317+
318+
.{ "mc1.port1.postMessage('msg2');", "undefined" },
319+
.{ "message", "msg2" },
320+
.{ "target == mc1.port2", "true" },
321+
.{ "currentTarget == mc1.port2", "true" },
322+
323+
.{ "message = null", null },
324+
.{ "mc1.port1.close();", null },
325+
.{ "mc1.port1.postMessage('msg3');", "undefined" },
326+
.{ "message", "null" },
327+
}, .{});
328+
329+
try runner.testCases(&.{
330+
.{ "const mc2 = new MessageChannel()", null },
331+
.{ "mc2.port2.postMessage('msg1');", "undefined" },
332+
.{ "mc2.port1.postMessage('msg2');", "undefined" },
333+
.{
334+
\\ let message1 = null;
335+
\\ mc2.port1.addEventListener('message', (e) => {
336+
\\ message1 = e.data;
337+
\\ });
338+
,
339+
null,
340+
},
341+
.{
342+
\\ let message2 = null;
343+
\\ mc2.port2.addEventListener('message', (e) => {
344+
\\ message2 = e.data;
345+
\\ });
346+
,
347+
null,
348+
},
349+
.{ "message1", "null" },
350+
.{ "message2", "null" },
351+
.{ "mc2.port2.start()", null },
352+
353+
.{ "message1", "null" },
354+
.{ "message2", "msg2" },
355+
.{ "message2 = null", null },
356+
357+
.{ "mc2.port1.start()", null },
358+
.{ "message1", "msg1" },
359+
.{ "message2", "null" },
360+
}, .{});
361+
}

src/browser/dom/dom.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,5 @@ pub const Interfaces = .{
5353
PerformanceObserver,
5454
@import("range.zig").Interfaces,
5555
@import("Animation.zig"),
56+
@import("MessageChannel.zig").Interfaces,
5657
};

src/browser/dom/event_target.zig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub const Union = union(enum) {
3030
node: nod.Union,
3131
xhr: *@import("../xhr/xhr.zig").XMLHttpRequest,
3232
plain: *parser.EventTarget,
33+
message_port: *@import("MessageChannel.zig").MessagePort,
3334
};
3435

3536
// EventTarget implementation
@@ -63,6 +64,9 @@ pub const EventTarget = struct {
6364
const base: *XMLHttpRequestEventTarget = @fieldParentPtr("base", @as(*parser.EventTargetTBase, @ptrCast(et)));
6465
return .{ .xhr = @fieldParentPtr("proto", base) };
6566
},
67+
.message_port => {
68+
return .{ .message_port = @fieldParentPtr("proto", @as(*parser.EventTargetTBase, @ptrCast(et))) };
69+
},
6670
else => return error.MissingEventTargetType,
6771
}
6872
}

0 commit comments

Comments
 (0)