Merge pull request #2977 from lightpanda-io/SharedWorker

webapi: Add SharedWorker
This commit is contained in:
Karl Seguin
2026-07-17 07:49:19 +08:00
committed by GitHub
19 changed files with 935 additions and 65 deletions

View File

@@ -53,6 +53,7 @@ const VisualViewport = @import("webapi/VisualViewport.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
const DOMNodeIterator = @import("webapi/DOMNodeIterator.zig");
const Worker = @import("webapi/Worker.zig");
const MessagePort = @import("webapi/MessagePort.zig");
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
@@ -198,6 +199,9 @@ _live_node_iterators: std.DoublyLinkedList = .{},
// channels in this frame's origin
_broadcast_channels: std.DoublyLinkedList = .{},
// List of MessagePorts living in this frame's context.
_message_ports: std.DoublyLinkedList = .{},
// MutationObserver / IntersectionObserver bookkeeping. See frame/observers.zig.
_mutation: observers.Mutation = .{},
_intersection: observers.Intersection = .{},
@@ -477,6 +481,11 @@ pub fn deinit(self: *Frame) void {
page.releaseArena(qn.arena);
}
while (self._message_ports.first) |node| {
const port: *MessagePort = @alignCast(@fieldParentPtr("_node", node));
port.close(); // removes from self._message_ports
}
{
// Release all objects we're referencing
{

View File

@@ -27,6 +27,8 @@ const Session = @import("Session.zig");
const Factory = @import("Factory.zig");
const Viewport = @import("Viewport.zig");
const SharedWorkerGlobalScope = @import("webapi/SharedWorkerGlobalScope.zig");
const v8 = js.v8;
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
@@ -116,6 +118,10 @@ popups: std.ArrayList(*Frame) = .empty,
// ideal from a memory point of view).
closed_frames: std.ArrayList(*Frame) = .empty,
// SharedWorkerGlobalScopes created by this Page's frames (also registered in
// session.shared_workers so other pages can connect).
shared_workers: std.ArrayList(*SharedWorkerGlobalScope) = .empty,
// In-flight navigation for a root page. When not null, this page will "replace"
// the referenced page once the response header arrives. This is necessary
// because, during navigation, both the "old" and "new" pages remain addressable
@@ -166,6 +172,11 @@ pub fn deinit(self: *Page) void {
self.frame.deinit();
for (self.shared_workers.items) |scope| {
scope.deinit();
}
self.shared_workers = .empty;
const session = self.session;
lp.metrics.js_heap_size_bytes.observe(session.browser.env.isolate.getHeapStatistics().total_physical_size);
defer session.browser.env.memoryPressureNotification(.moderate);

View File

@@ -33,6 +33,7 @@ const Browser = @import("Browser.zig");
pub const Runner = @import("Runner.zig");
const Notification = @import("../Notification.zig");
const QueuedNavigation = Frame.QueuedNavigation;
const SharedWorkerGlobalScope = @import("webapi/SharedWorkerGlobalScope.zig");
const log = lp.log;
const ArenaPool = App.ArenaPool;
@@ -68,6 +69,11 @@ arena_pool: *ArenaPool,
// both the live page and its in-flight replacement.
pages: std.ArrayList(*Page) = .{},
// Live SharedWorkerGlobalScopes, keyed by "url\x00name", so every
// `new SharedWorker(url, name)` in the session connects to the same instance.
// Owned by the Page that creates it.
shared_workers: std.StringHashMapUnmanaged(*SharedWorkerGlobalScope) = .empty,
_page_destruction_queue: std.ArrayList(*Page) = .{},
// Round-robin cursor for fair page iteration (processQueuedNavigation)

View File

@@ -31,6 +31,7 @@ const App = @import("../../App.zig");
const Frame = @import("../Frame.zig");
const Window = @import("../webapi/Window.zig");
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig");
const SharedWorkerGlobalScope = @import("../webapi/SharedWorkerGlobalScope.zig");
const DedicatedWorkerGlobalScope = @import("../webapi/DedicatedWorkerGlobalScope.zig");
const v8 = js.v8;
@@ -274,8 +275,12 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
break :blk @ptrCast(v8.v8__Global__Get(existing, isolate.handle));
};
// Restore the context from the snapshot (0 = Page, 1 = Worker)
const snapshot_index: u32 = if (comptime is_frame) 0 else 1;
// Restore the context from the snapshot
// (0 = Page, 1 = DedicatedWorker, 2 = SharedWorker)
const snapshot_index: u32 = if (comptime is_frame) 0 else switch (global._type) {
.dedicated => 1,
.shared => 2,
};
const v8_context = v8.v8__Context__FromSnapshot__Config(isolate.handle, snapshot_index, &.{
.global_template = null,
.global_object = reuse_global_object,
@@ -297,11 +302,19 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
.prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len),
.subtype = .node,
} else .{
.value = @ptrCast(global._type.dedicated),
.prototype_chain = (&DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain.len),
.subtype = null,
} else switch (global._type) {
.dedicated => |scope| .{
.value = @ptrCast(scope),
.prototype_chain = (&DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain.len),
.subtype = null,
},
.shared => |scope| .{
.value = @ptrCast(scope),
.prototype_chain = (&SharedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(SharedWorkerGlobalScope.JsApi.Meta.prototype_chain.len),
.subtype = null,
},
};
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
@@ -349,7 +362,9 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
// Register in the identity map. Multiple contexts can be created for the
// same global (via CDP), so we only register the first one.
const identity_ptr = if (comptime is_frame) @intFromPtr(global.window) else @intFromPtr(global._type.dedicated);
const identity_ptr = if (comptime is_frame) @intFromPtr(global.window) else switch (global._type) {
inline else => |scope| @intFromPtr(scope),
};
const gop = try params.identity.identity_map.getOrPut(params.identity_arena, identity_ptr);
if (gop.found_existing == false) {
var global_global: v8.Global = undefined;

View File

@@ -115,6 +115,14 @@ pub fn getBroadcastChannels(self: *const Execution) *std.DoublyLinkedList {
};
}
// The owning global's (Frame or WGS) list of live MessagePorts, walked at
// that global's teardown to sever cross-context entanglement.
pub fn messagePorts(self: *const Execution) *std.DoublyLinkedList {
return switch (self.js.global) {
inline else => |g| &g._message_ports,
};
}
// The global's serialized origin (e.g. "https://example.com"), or null for an
// opaque origin.
pub fn origin(self: *const Execution) ?[]const u8 {

View File

@@ -31,7 +31,9 @@ const v8 = js.v8;
const log = lp.log;
const JsApis = bridge.JsApis;
const PageJsApis = bridge.PageJsApis;
const WorkerJsApis = bridge.WorkerJsApis;
const SharedWorkerJsApis = bridge.SharedWorkerJsApis;
const DedicatedWorkerJsApis = bridge.DedicatedWorkerJsApis;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Snapshot = @This();
@@ -209,9 +211,15 @@ pub fn create() !Snapshot {
{
const DedicatedWorkerGlobalScope = @import("../webapi/DedicatedWorkerGlobalScope.zig");
const index = try createSnapshotContext(.worker, &WorkerJsApis, DedicatedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates);
const index = try createSnapshotContext(.worker, &DedicatedWorkerJsApis, DedicatedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates);
std.debug.assert(index == 1);
}
{
const SharedWorkerGlobalScope = @import("../webapi/SharedWorkerGlobalScope.zig");
const index = try createSnapshotContext(.worker, &SharedWorkerJsApis, SharedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates);
std.debug.assert(index == 2);
}
}
const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep);

View File

@@ -1114,6 +1114,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/MessagePort.zig"),
@import("../webapi/BroadcastChannel.zig"),
@import("../webapi/Worker.zig"),
@import("../webapi/SharedWorker.zig"),
@import("../webapi/media/MediaError.zig"),
@import("../webapi/media/TextTrackCue.zig"),
@import("../webapi/media/VTTCue.zig"),
@@ -1182,11 +1183,13 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/collections/DOMStringList.zig"),
});
// APIs available on Worker context globals (constructors like URL, Headers, etc.)
// APIs available on every Worker context global (constructors like URL,
// Headers, etc.), regardless of worker kind. Each kind's snapshot context
// adds its own global-scope type on top (DedicatedWorkerJsApis,
// SharedWorkerJsApis below).
// This is a subset of PageJsApis plus WorkerGlobalScope.
// TODO: Expand this list to include all worker-appropriate APIs.
pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/DedicatedWorkerGlobalScope.zig"),
const worker_common_apis = [_]type{
@import("../webapi/WorkerGlobalScope.zig"),
@import("../webapi/WorkerLocation.zig"),
@import("../webapi/Navigator.zig"),
@@ -1253,7 +1256,10 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
@import("../webapi/collections/DOMStringList.zig"),
});
};
pub const DedicatedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/DedicatedWorkerGlobalScope.zig")} ++ worker_common_apis));
pub const SharedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/SharedWorkerGlobalScope.zig")} ++ worker_common_apis));
// Master list of ALL JS APIs across all contexts.
// Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs
@@ -1262,6 +1268,7 @@ pub const WorkerJsApis = flattenTypes(&.{
pub const JsApis = blk: {
const base = PageJsApis ++ [_]type{
@import("../webapi/DedicatedWorkerGlobalScope.zig").JsApi,
@import("../webapi/SharedWorkerGlobalScope.zig").JsApi,
@import("../webapi/WorkerGlobalScope.zig").JsApi,
@import("../webapi/WorkerLocation.zig").JsApi,
};

View File

@@ -0,0 +1,8 @@
let counter = 0;
onconnect = (e) => {
const port = e.ports[0];
port.onmessage = () => {
counter++;
port.postMessage({ count: counter, name: self.name });
};
};

View File

@@ -0,0 +1,8 @@
onconnect = (e) => {
// Per spec the connect event's source is the port itself (testharness.js
// relies on this rather than ports[0]).
const port = e.source;
port.onmessage = (event) => {
port.postMessage({ echo: event.data, from: 'shared-worker', sourceIsPort: e.source === e.ports[0] });
};
};

View File

@@ -0,0 +1,10 @@
// Exercises the addEventListener flavor on both the global ('connect') and
// the port ('message' + explicit start()), instead of the auto-starting
// onconnect/onmessage attribute setters.
self.addEventListener('connect', (e) => {
const port = e.ports[0];
port.addEventListener('message', (event) => {
port.postMessage('listener:' + event.data);
});
port.start();
});

View File

@@ -0,0 +1,137 @@
<!DOCTYPE html>
<body></body>
<script src="../testing.js"></script>
<script id="shared_worker_creation">
{
const worker = new SharedWorker('./echo-worker.js');
testing.expectTrue(worker instanceof SharedWorker);
testing.expectTrue(worker.port instanceof MessagePort);
}
</script>
<script id="shared_worker_echo" type=module>
// Also exercises post-before-load: the message is posted while the worker
// script's fetch is still in flight, so it must sit in the worker-side
// port's queue until the connect handler assigns port.onmessage.
{
const state = await testing.async();
const worker = new SharedWorker('./echo-worker.js');
worker.port.onmessage = (event) => {
state.resolve(event.data);
};
worker.port.postMessage({ greeting: 'hello' });
await state.done((response) => {
testing.expectEqual('hello', response.echo.greeting);
testing.expectEqual('shared-worker', response.from);
testing.expectEqual(true, response.sourceIsPort);
});
}
</script>
<script id="shared_worker_shared_instance" type=module>
// Two SharedWorker handles for the same (url, name) are distinct JS
// objects but connect to the same worker: the counter is shared.
{
const state = await testing.async();
const w1 = new SharedWorker('./counter-worker.js');
const w2 = new SharedWorker('./counter-worker.js');
testing.expectTrue(w1 !== w2);
testing.expectTrue(w1.port !== w2.port);
const incr = (w) => new Promise((resolve) => {
w.port.onmessage = (e) => resolve(e.data);
w.port.postMessage('incr');
});
const c1 = await incr(w1);
const c2 = await incr(w2);
state.resolve();
await state.done(() => {
testing.expectEqual(1, c1.count);
testing.expectEqual(2, c2.count);
testing.expectEqual('', c1.name);
});
}
</script>
<script id="shared_worker_named_isolation" type=module>
// Different names on the same url are different workers, and each worker
// sees its own name. The second argument can be a string or an options
// object.
{
const state = await testing.async();
const wa = new SharedWorker('./counter-worker.js', 'a');
const wb = new SharedWorker('./counter-worker.js', { name: 'b' });
const incr = (w) => new Promise((resolve) => {
w.port.onmessage = (e) => resolve(e.data);
w.port.postMessage('incr');
});
const ca = await incr(wa);
const cb = await incr(wb);
state.resolve();
await state.done(() => {
testing.expectEqual(1, ca.count);
testing.expectEqual('a', ca.name);
testing.expectEqual(1, cb.count);
testing.expectEqual('b', cb.name);
});
}
</script>
<script id="shared_worker_add_event_listener" type=module>
// addEventListener('message') on the page-side port does not auto-start
// it; an explicit start() is required. The worker script likewise uses
// addEventListener('connect') + port.start().
{
const state = await testing.async();
const worker = new SharedWorker('./listener-worker.js');
worker.port.addEventListener('message', (event) => {
state.resolve(event.data);
});
worker.port.start();
worker.port.postMessage('ping');
await state.done((response) => {
testing.expectEqual('listener:ping', response);
});
}
</script>
<script id="shared_worker_structured_clone" type=module>
// Messages cross a context boundary: the receiver gets an independent
// structured clone, and unserializable values throw a DataCloneError.
{
const state = await testing.async();
const worker = new SharedWorker('./echo-worker.js');
let threw = false;
try {
worker.port.postMessage(function () {});
} catch (e) {
threw = true;
testing.expectEqual('DataCloneError', e.name);
}
testing.expectEqual(true, threw);
const payload = { date: new Date('2024-06-15T12:30:00Z'), arr: [1, 2, 3] };
worker.port.onmessage = (event) => {
state.resolve(event.data);
};
worker.port.postMessage(payload);
payload.arr.push(4); // must not be visible to the worker
await state.done((response) => {
testing.expectTrue(response.echo.date instanceof Date);
testing.expectEqual(payload.date.getTime(), response.echo.date.getTime());
testing.expectEqual(3, response.echo.arr.length);
});
}
</script>

View File

@@ -37,6 +37,7 @@ pub const Type = union(enum) {
node: *@import("Node.zig"),
window: *@import("Window.zig"),
worker: *@import("Worker.zig"),
shared_worker: *@import("SharedWorker.zig"),
worker_global_scope: *@import("WorkerGlobalScope.zig"),
xhr: *@import("net/XMLHttpRequestEventTarget.zig"),
abort_signal: *@import("AbortSignal.zig"),
@@ -214,6 +215,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.generic => writer.writeAll("<EventTarget>"),
.window => writer.writeAll("<Window>"),
.worker => writer.writeAll("<Worker>"),
.shared_worker => writer.writeAll("<SharedWorker>"),
.worker_global_scope => writer.writeAll("<WorkerGlobalScope>"),
.xhr => writer.writeAll("<XMLHttpRequestEventTarget>"),
.abort_signal => writer.writeAll("<AbortSignal>"),
@@ -243,6 +245,7 @@ pub fn toString(self: *EventTarget) []const u8 {
.generic => return "[object EventTarget]",
.window => return "[object Window]",
.worker => return "[object Worker]",
.shared_worker => return "[object SharedWorker]",
.worker_global_scope => return "[object WorkerGlobalScope]",
.xhr => return "[object XMLHttpRequestEventTarget]",
.abort_signal => return "[object AbortSignal]",

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
@@ -29,16 +30,30 @@ const Execution = js.Execution;
const MessagePort = @This();
_proto: *EventTarget,
// The context this port lives in. The two ends of an entangled pair can be in
// different contexts. Delivery always goes through the *receiving* port's context.
_exec: *Execution,
_enabled: bool = false,
_closed: bool = false,
_on_message: ?js.Function.Global = null,
_on_message_error: ?js.Function.Global = null,
_entangled_port: ?*MessagePort = null,
// queued message received before the port is started.
_pending: std.ArrayList(js.Value.Global) = .empty,
// Link list into the owning Frame or WorkerGlobalScope. When the frame/WGS is
// shutdown, the port will be closed.
_node: std.DoublyLinkedList.Node = .{},
pub fn init(exec: *Execution) !*MessagePort {
return exec._factory.eventTarget(MessagePort{
const self = try exec._factory.eventTarget(MessagePort{
._proto = undefined,
._exec = exec,
});
exec.messagePorts().append(&self._node);
return self;
}
pub fn asEventTarget(self: *MessagePort) *EventTarget {
@@ -50,7 +65,7 @@ pub fn entangle(port1: *MessagePort, port2: *MessagePort) void {
port2._entangled_port = port1;
}
pub fn postMessage(self: *MessagePort, message: js.Value, exec: *Execution) !void {
pub fn postMessage(self: *MessagePort, message: js.Value) !void {
if (self._closed) {
return;
}
@@ -60,13 +75,14 @@ pub fn postMessage(self: *MessagePort, message: js.Value, exec: *Execution) !voi
return;
}
// StructuredSerialize runs synchronously (per spec): clone the message into a
// fresh, self-owned temp now so the receiver gets an independent copy (a
// mutation on one side isn't visible to the other) and an unserializable
// value throws a DataCloneError to the caller. Mirrors Worker.postMessage.
// StructuredSerialize runs synchronously (per spec): clone the message now
// so the receiver gets an independent copy (a mutation on one side isn't
// visible to the other) and an unserializable value throws a DataCloneError
// to the caller. The clone is made directly into the receiving port's
// context, which may not be the sender's.
const cloned = blk: {
var ls: js.Local.Scope = undefined;
exec.js.localScope(&ls);
other._exec.js.localScope(&ls);
defer ls.deinit();
// Contain any V8 exception from a failed serialization so it surfaces as
@@ -82,29 +98,40 @@ pub fn postMessage(self: *MessagePort, message: js.Value, exec: *Execution) !voi
};
errdefer cloned.release();
// Create callback to deliver message
const callback = try exec._factory.create(PostMessageCallback{
.exec = exec,
.port = other,
.message = cloned,
});
if (!other._enabled) {
try other._pending.append(other._exec.arena, cloned);
return;
}
try exec.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
.name = "MessagePort.postMessage",
.low_priority = false,
.finalizer = PostMessageCallback.cancelled,
});
try other.scheduleDelivery(cloned);
}
pub fn start(self: *MessagePort) void {
if (self._closed) {
if (self._closed or self._enabled) {
return;
}
self._enabled = true;
for (self._pending.items) |message| {
self.scheduleDelivery(message) catch |err| {
log.warn(.dom, "MessagePort.start drain", .{ .err = err });
message.release();
};
}
self._pending.clearRetainingCapacity();
}
pub fn close(self: *MessagePort) void {
if (self._closed) {
return;
}
self._closed = true;
self._exec.messagePorts().remove(&self._node);
for (self._pending.items) |message| {
message.release();
}
self._pending.clearRetainingCapacity();
// Break entanglement
if (self._entangled_port) |other| {
@@ -119,6 +146,9 @@ pub fn getOnMessage(self: *const MessagePort) ?js.Function.Global {
pub fn setOnMessage(self: *MessagePort, cb: ?js.Function.Global) !void {
self._on_message = cb;
if (cb != null) {
self.start();
}
}
pub fn getOnMessageError(self: *const MessagePort) ?js.Function.Global {
@@ -129,38 +159,55 @@ pub fn setOnMessageError(self: *MessagePort, cb: ?js.Function.Global) !void {
self._on_message_error = cb;
}
const PostMessageCallback = struct {
// Queues delivery of `message` (a clone already living in this port's
// context) on this port's scheduler.
fn scheduleDelivery(self: *MessagePort, message: js.Value.Global) !void {
const exec = self._exec;
const callback = try exec._factory.create(DeliverCallback{
.port = self,
.message = message,
});
errdefer exec._factory.destroy(callback);
try exec.js.scheduler.add(callback, DeliverCallback.run, 0, .{
.name = "MessagePort.postMessage",
.low_priority = false,
.finalizer = DeliverCallback.cancelled,
});
}
const DeliverCallback = struct {
port: *MessagePort,
message: js.Value.Global,
exec: *Execution,
// Called by the scheduler if the task is dropped before it runs. `run` and
// `cancelled` are mutually exclusive, so the temp is released exactly once.
fn cancelled(ctx: *anyopaque) void {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
const self: *DeliverCallback = @ptrCast(@alignCast(ctx));
self.message.release();
self.deinit();
}
fn deinit(self: *PostMessageCallback) void {
self.exec._factory.destroy(self);
fn deinit(self: *DeliverCallback) void {
self.port._exec._factory.destroy(self);
}
fn run(ctx: *anyopaque) !?u32 {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
const self: *DeliverCallback = @ptrCast(@alignCast(ctx));
defer self.deinit();
const exec = self.exec;
const port = self.port;
const exec = port._exec;
// The MessageEvent takes ownership of the cloned temp and releases it on
// teardown; on any path where we don't hand it over, release it here so
// it doesn't leak.
if (self.port._closed) {
if (port._closed) {
self.message.release();
return null;
}
const target = self.port.asEventTarget();
if (!exec.hasDirectListeners(target, "message", self.port._on_message)) {
const target = port.asEventTarget();
if (!exec.hasDirectListeners(target, "message", port._on_message)) {
self.message.release();
return null;
}
@@ -175,7 +222,7 @@ const PostMessageCallback = struct {
return null;
}).asEvent();
exec.dispatch(target, event, self.port._on_message, .{ .context = "MessagePort message" }) catch |err| {
exec.dispatch(target, event, port._on_message, .{ .context = "MessagePort message" }) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
};

View File

@@ -0,0 +1,146 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
// The page-side handle for a shared worker. Unlike Worker, this owns nothing:
// the worker itself (SharedWorkerGlobalScope) is owned by the page that first
// created it and found through the Session's registry, keyed by
// (resolved url, name) — so every `new SharedWorker(url, name)` in the
// session, from any frame or page, talks to the same instance. All this
// handle keeps is its end of the connection's MessagePort pair.
//
// Divergence from the spec: the worker's lifetime is tied to its creating
// page, not to the set of connected clients. If the creator dies while
// another page is still connected, the worker dies and the surviving port
// goes quiet (entanglement is severed; nothing dangles).
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const URL = @import("../URL.zig");
const Frame = @import("../Frame.zig");
const Worker = @import("Worker.zig");
const EventTarget = @import("EventTarget.zig");
const MessagePort = @import("MessagePort.zig");
const SharedWorkerGlobalScope = @import("SharedWorkerGlobalScope.zig");
const log = lp.log;
const SharedWorker = @This();
_proto: *EventTarget,
_port: *MessagePort,
_on_error: ?js.Function.Global = null,
const NameOrOpts = union(enum) {
name: []const u8,
options: Opts,
const Opts = struct {
name: []const u8 = "",
type: Worker.WorkerType = .classic,
};
};
pub fn init(url: []const u8, name_or_options: ?NameOrOpts, frame: *Frame) !*SharedWorker {
const options: NameOrOpts.Opts = if (name_or_options) |noo| switch (noo) {
.name => |n| .{ .name = n },
.options => |o| o,
} else .{};
const resolved_url = try URL.resolve(frame.call_arena, frame.base(), url, .{ .encoding = frame.charset });
const scope = blk: {
const session = frame._session;
// \x00 can appear in neither a URL nor a name
const lookup_key = try std.fmt.allocPrint(frame.call_arena, "{s}\x00{s}", .{ resolved_url, options.name });
if (session.shared_workers.get(lookup_key)) |existing| {
break :blk existing;
}
const s = try SharedWorkerGlobalScope.init(frame, resolved_url, options.name, options.type);
errdefer s.deinit();
const page = frame._page;
try page.shared_workers.append(page.frame_arena, s);
errdefer _ = page.shared_workers.pop();
try s.register(lookup_key);
break :blk s;
};
const port = try scope.connect(&frame.js.execution);
return frame._page.factory.eventTarget(SharedWorker{
._proto = undefined,
._port = port,
});
}
pub fn asEventTarget(self: *SharedWorker) *EventTarget {
return self._proto;
}
pub fn getPort(self: *const SharedWorker) *MessagePort {
return self._port;
}
pub fn getOnError(self: *const SharedWorker) ?js.Function.Global {
return self._on_error;
}
pub fn setOnError(self: *SharedWorker, setter: ?FunctionSetter) void {
self._on_error = getFunctionFromSetter(setter);
}
const FunctionSetter = union(enum) {
func: js.Function.Global,
anything: js.Value,
};
fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global {
const setter = setter_ orelse return null;
return switch (setter) {
.func => |func| func,
.anything => null,
};
}
pub const JsApi = struct {
pub const bridge = js.Bridge(SharedWorker);
pub const Meta = struct {
pub const name = "SharedWorker";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(SharedWorker.init, .{});
pub const port = bridge.accessor(SharedWorker.getPort, null, .{});
pub const onerror = bridge.accessor(SharedWorker.getOnError, SharedWorker.setOnError, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: SharedWorker" {
const filter: testing.LogFilter = .init(&.{.http});
defer filter.deinit();
try testing.htmlRunner("shared_worker", .{ .timeout_ms = 8000 });
}

View File

@@ -0,0 +1,407 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const URL = @import("../URL.zig");
const Frame = @import("../Frame.zig");
const Transfer = @import("../../network/HttpClient.zig").Transfer;
const Worker = @import("Worker.zig");
const MessagePort = @import("MessagePort.zig");
const WorkerGlobalScope = @import("WorkerGlobalScope.zig");
const MessageEvent = @import("event/MessageEvent.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const SharedWorkerGlobalScope = @This();
_proto: *WorkerGlobalScope,
_arena: Allocator,
_url: [:0]const u8,
_name: []const u8,
_type: Worker.WorkerType,
// Key under which this scope is registered in session.shared_workers. Empty
// until registered; deinit/close use it to unregister.
_registry_key: []const u8 = "",
// used by HttpClient when generating notification
// Ultimately used by CDP to generate request/loader ids.
_frame_id: u32,
_loader_id: u32,
_closed: bool = false,
_script_loaded: bool = false,
_script_arena: ?Allocator = null,
_script_buffer: std.ArrayList(u8) = .empty,
_http_transfer: ?*Transfer = null,
_on_connect: ?js.Function.Global = null,
// Ports pending connnection, created before the initial script finished
_pending_connects: std.ArrayList(*MessagePort) = .empty,
pub fn init(frame: *Frame, url: [:0]const u8, name: []const u8, worker_type: Worker.WorkerType) !*SharedWorkerGlobalScope {
const session = frame._session;
const arena = try session.getArena(.small, "SharedWorker");
errdefer session.releaseArena(arena);
const owned_url = try arena.dupeZ(u8, url);
const self = try arena.create(SharedWorkerGlobalScope);
const proto = try WorkerGlobalScope.init(
arena,
owned_url,
.{ .shared = self },
worker_type == .module,
session.nextFrameId(),
session.nextLoaderId(),
frame,
);
self.* = .{
._proto = proto,
._arena = arena,
._url = owned_url,
._name = try arena.dupe(u8, name),
._type = worker_type,
._frame_id = proto._frame_id,
._loader_id = proto._loader_id,
};
errdefer proto.deinit();
if (!session.worker_loading_enabled) {
log.debug(.browser, "shared worker disabled", .{ .url = owned_url });
return self;
}
self._script_arena = try session.getArena(.large, "SharedWorker.script");
errdefer self.releaseScriptArena();
const transfer = proto.newRequest(.{
.ctx = self,
.method = .GET,
.url = owned_url,
.frame_id = self._frame_id,
.loader_id = self._loader_id,
.resource_type = .script,
.cookie_jar = &session.cookie_jar,
.cookie_origin = owned_url,
.notification = session.notification,
.header_callback = httpHeaderCallback,
.data_callback = httpDataCallback,
.done_callback = httpDoneCallback,
.error_callback = httpErrorCallback,
.shutdown_callback = httpShutdownCallback,
}) catch |err| {
log.err(.browser, "SharedWorker request", .{ .url = owned_url, .err = err });
return err;
};
// Held for deinit's abort; the done, error and shutdown callbacks clear
// it. The shutdown one matters: WorkerGlobalScope.deinit's abortOwner
// kills the transfer, and deinit must not abort a freed transfer.
self._http_transfer = transfer;
transfer.submit() catch |err| {
log.err(.browser, "SharedWorker request", .{ .url = owned_url, .err = err });
return err;
};
return self;
}
// Called from Page.deinit of the owning (creating) page.
pub fn deinit(self: *SharedWorkerGlobalScope) void {
if (self._http_transfer) |transfer| {
transfer.abort(error.Abort);
self._http_transfer = null;
}
self.releaseScriptArena();
self.unregister();
self._proto.deinit();
self._proto._session.releaseArena(self._arena);
}
pub fn register(self: *SharedWorkerGlobalScope, lookup_key: []const u8) !void {
// The key lives in our arena: every removal path (deinit, close) unregisters
// before the arena is released.
const key = try self._arena.dupe(u8, lookup_key);
const session = self._proto._session;
try session.shared_workers.put(session.arena, key, self);
self._registry_key = key;
}
fn unregister(self: *SharedWorkerGlobalScope) void {
if (self._registry_key.len == 0) {
return;
}
_ = self._proto._session.shared_workers.remove(self._registry_key);
self._registry_key = "";
}
// Establishes a new connection from a client context: creates the entangled
// port pair and queues the connect event. Returns the client's end.
pub fn connect(self: *SharedWorkerGlobalScope, client_exec: *js.Execution) !*MessagePort {
const client_port = try MessagePort.init(client_exec);
const worker_port = try MessagePort.init(&self._proto.js.execution);
MessagePort.entangle(client_port, worker_port);
if (self._script_loaded == false) {
try self._pending_connects.append(self._arena, worker_port);
return client_port;
}
try self.scheduleConnect(worker_port);
return client_port;
}
pub fn getName(self: *const SharedWorkerGlobalScope) []const u8 {
return self._name;
}
pub fn close(self: *SharedWorkerGlobalScope) void {
// Once closed, new SharedWorker(url, name) must create a fresh instance.
self.unregister();
// TODO: we should also stop new tasks from being scheduled
self._proto._session.idb.detachContext(self._proto.js);
self._proto.js.scheduler.reset();
self._closed = true;
}
pub fn getOnConnect(self: *const SharedWorkerGlobalScope) ?js.Function.Global {
return self._on_connect;
}
pub fn setOnConnect(self: *SharedWorkerGlobalScope, setter: ?WorkerGlobalScope.FunctionSetter) void {
self._on_connect = WorkerGlobalScope.getFunctionFromSetter(setter);
}
fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(transfer.req.ctx));
const status = transfer.responseStatus() orelse return .abort;
if (status < 200 or status >= 300) {
log.warn(.browser, "SharedWorker status", .{
.url = self._url,
.status = status,
});
return .abort;
}
if (transfer.getContentLength()) |cl| {
try self._script_buffer.ensureTotalCapacity(self._script_arena.?, cl);
}
return .proceed;
}
fn httpDataCallback(transfer: *Transfer, data: []const u8) !void {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(transfer.req.ctx));
try self._script_buffer.appendSlice(self._script_arena.?, data);
}
fn httpDoneCallback(ctx: *anyopaque) !void {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(ctx));
self._http_transfer = null;
defer self.releaseScriptArena();
const url = self._url;
const script = self._script_buffer.items;
if (comptime IS_DEBUG) {
log.info(.browser, "shared worker fetch done", .{
.url = url,
.len = script.len,
});
}
try self.loadInitialScript(script);
}
fn httpShutdownCallback(ctx: *anyopaque) void {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(ctx));
self._http_transfer = null;
self.releaseScriptArena();
}
fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(ctx));
self._http_transfer = null;
self.releaseScriptArena();
log.err(.browser, "shared worker fetch error", .{
.url = self._url,
.err = err,
});
// The worker will never load and onconnect will never be registered.
// Drain the buffered connects so they get dispatched (and dropped at the
// "no listener" check) rather than accumulating until teardown.
self._script_loaded = true;
self.drainPendingConnects();
}
fn loadInitialScript(self: *SharedWorkerGlobalScope, script: []const u8) !void {
const js_context = self._proto.js;
if (js_context.env.terminatePending()) {
return;
}
// The flip-and-drain runs after eval (and the trailing runMacrotasks) —
// by which point the outer script has had its only chance to register
// onconnect. On eval-throw the defer still fires; the connect events get
// scheduled and then drop at the "no listener" check.
defer {
self._script_loaded = true;
self.drainPendingConnects();
}
var ls: js.Local.Scope = undefined;
js_context.localScope(&ls);
defer ls.deinit();
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
switch (self._type) {
.classic => _ = ls.local.eval(script, self._url) catch |err| {
if (js_context.env.terminatePending()) {
return;
}
const caught = try_catch.caughtOrError(self._script_arena.?, err);
log.err(.browser, "shared worker script error", .{ .url = self._url, .caught = caught });
return;
},
.module => js_context.module(false, &ls.local, script, self._url, true) catch |err| {
if (js_context.env.terminatePending()) {
return;
}
const caught = try_catch.caughtOrError(self._script_arena.?, err);
log.err(.browser, "shared worker module error", .{ .url = self._url, .caught = caught });
return;
},
}
ls.local.runMacrotasks();
}
// Idempotent: reached from the done, error and shutdown callbacks and from
// deinit (the abort there re-enters via the error callback).
fn releaseScriptArena(self: *SharedWorkerGlobalScope) void {
const arena = self._script_arena orelse return;
self._script_arena = null;
self._script_buffer = .empty;
self._proto._session.releaseArena(arena);
}
fn drainPendingConnects(self: *SharedWorkerGlobalScope) void {
for (self._pending_connects.items) |port| {
self.scheduleConnect(port) catch |err| {
log.warn(.browser, "shared worker drain connect failed", .{ .err = err });
};
}
self._pending_connects.clearRetainingCapacity();
}
fn scheduleConnect(self: *SharedWorkerGlobalScope, port: *MessagePort) !void {
const wgs = self._proto;
const session = wgs._session;
const connect_arena = try session.getArena(.tiny, "SharedWorkerGlobalScope.connect");
errdefer session.releaseArena(connect_arena);
const callback = try connect_arena.create(ConnectCallback);
callback.* = .{
.port = port,
.worker_scope = self,
.arena = connect_arena,
};
try wgs.js.scheduler.add(callback, ConnectCallback.run, 0, .{
.name = "SharedWorkerGlobalScope.connect",
.low_priority = false,
.finalizer = ConnectCallback.cancelled,
});
}
const ConnectCallback = struct {
port: *MessagePort,
arena: Allocator,
worker_scope: *SharedWorkerGlobalScope,
fn cancelled(ctx: *anyopaque) void {
const self: *ConnectCallback = @ptrCast(@alignCast(ctx));
self.deinit();
}
fn deinit(self: *ConnectCallback) void {
self.worker_scope._proto._session.releaseArena(self.arena);
}
fn run(ctx: *anyopaque) !?u32 {
const self: *ConnectCallback = @ptrCast(@alignCast(ctx));
defer self.deinit();
const worker_scope = self.worker_scope;
const wgs = worker_scope._proto;
const target = wgs.asEventTarget();
const on_connect = worker_scope._on_connect;
if (!wgs._event_manager.hasDirectListeners(target, "connect", on_connect)) {
return null;
}
// Per spec the connect event's data is the empty string and the new
// port is both the sole entry of `ports` and the `source`.
const event = (try MessageEvent.initTrusted(comptime .wrap("connect"), .{
.data = .{ .string = "" },
.source = .{ .port = self.port },
.ports = &.{self.port},
.bubbles = false,
.cancelable = false,
}, wgs._page)).asEvent();
try wgs.dispatch(target, event, on_connect, .{ .context = "SharedWorkerGlobalScope.connect" });
return null;
}
};
pub const JsApi = struct {
pub const bridge = js.Bridge(SharedWorkerGlobalScope);
pub const Meta = struct {
pub const name = "SharedWorkerGlobalScope";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const name = bridge.accessor(SharedWorkerGlobalScope.getName, null, .{});
pub const close = bridge.function(SharedWorkerGlobalScope.close, .{});
pub const onconnect = bridge.accessor(SharedWorkerGlobalScope.getOnConnect, SharedWorkerGlobalScope.setOnConnect, .{});
};

View File

@@ -1093,7 +1093,7 @@ const PostMessageCallback = struct {
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = .{ .value = self.message },
.origin = self.origin,
.source = self.source,
.source = .{ .window = self.source },
.ports = self.ports,
.bubbles = false,
.cancelable = false,

View File

@@ -55,6 +55,7 @@ _worker_scope: *DedicatedWorkerGlobalScope,
_url: [:0]const u8,
_type: WorkerType = .classic,
_script_loaded: bool = false,
_script_arena: ?Allocator = null,
_script_buffer: std.ArrayList(u8) = .empty,
_http_transfer: ?*Transfer = null,
@@ -70,7 +71,7 @@ const WorkerOptions = struct {
pub fn init(url: []const u8, options: ?WorkerOptions, frame: *Frame) !*Worker {
const session = frame._session;
const arena = try session.getArena(.large, "Worker");
const arena = try session.getArena(.small, "Worker");
errdefer session.releaseArena(arena);
const resolved_url = try URL.resolve(arena, frame.base(), url, .{ .encoding = frame.charset });
@@ -101,6 +102,9 @@ pub fn init(url: []const u8, options: ?WorkerOptions, frame: *Frame) !*Worker {
return self;
}
self._script_arena = try session.getArena(.large, "Worker.script");
errdefer self.releaseScriptArena();
const transfer = frame.newRequest(.{
.ctx = self,
.method = .GET,
@@ -144,6 +148,7 @@ pub fn deinit(self: *Worker) void {
res.abort(error.Abort);
self._http_transfer = null;
}
self.releaseScriptArena();
self._worker_scope.deinit();
self._frame._session.releaseArena(self._arena);
}
@@ -165,7 +170,7 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult {
}
if (transfer.getContentLength()) |cl| {
try self._script_buffer.ensureTotalCapacity(self._arena, cl);
try self._script_buffer.ensureTotalCapacity(self._script_arena.?, cl);
}
return .proceed;
@@ -173,12 +178,13 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult {
fn httpDataCallback(transfer: *Transfer, data: []const u8) !void {
const self: *Worker = @ptrCast(@alignCast(transfer.req.ctx));
try self._script_buffer.appendSlice(self._arena, data);
try self._script_buffer.appendSlice(self._script_arena.?, data);
}
fn httpDoneCallback(ctx: *anyopaque) !void {
const self: *Worker = @ptrCast(@alignCast(ctx));
self._http_transfer = null;
defer self.releaseScriptArena();
const url = self._url;
const script = self._script_buffer.items;
@@ -235,7 +241,7 @@ fn loadInitialScript(self: *Worker, script: []const u8) !void {
return;
}
const caught = try_catch.caughtOrError(self._arena, err);
const caught = try_catch.caughtOrError(self._script_arena.?, err);
log.err(.browser, "worker script error", .{ .url = self._url, .caught = caught });
self.fireErrorEvent(caught.exception orelse @errorName(err), null);
return;
@@ -245,7 +251,7 @@ fn loadInitialScript(self: *Worker, script: []const u8) !void {
return;
}
const caught = try_catch.caughtOrError(self._arena, err);
const caught = try_catch.caughtOrError(self._script_arena.?, err);
log.err(.browser, "worker module error", .{ .url = self._url, .caught = caught });
self.fireErrorEvent(caught.exception orelse @errorName(err), null);
return;
@@ -258,11 +264,13 @@ fn loadInitialScript(self: *Worker, script: []const u8) !void {
fn httpShutdownCallback(ctx: *anyopaque) void {
const self: *Worker = @ptrCast(@alignCast(ctx));
self._http_transfer = null;
self.releaseScriptArena();
}
fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *Worker = @ptrCast(@alignCast(ctx));
self._http_transfer = null;
self.releaseScriptArena();
log.err(.browser, "worker fetch error", .{
.url = self._url,
@@ -279,6 +287,13 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
self.fireErrorEvent(@errorName(err), null);
}
fn releaseScriptArena(self: *Worker) void {
const arena = self._script_arena orelse return;
self._script_arena = null;
self._script_buffer = .empty;
self._frame._session.releaseArena(arena);
}
// Fire an error event on the Worker object (parent context)
fn fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Global) void {
self._fireErrorEvent(message, error_value) catch |err| {

View File

@@ -46,6 +46,8 @@ const ErrorEvent = @import("event/ErrorEvent.zig");
const Fetch = @import("net/Fetch.zig");
const idb = @import("storage/idb/idb.zig");
const CookieStore = @import("storage/CookieStore.zig");
const MessagePort = @import("MessagePort.zig");
const SharedWorkerGlobalScope = @import("SharedWorkerGlobalScope.zig");
const DedicatedWorkerGlobalScope = @import("DedicatedWorkerGlobalScope.zig");
const builtin = @import("builtin");
@@ -98,6 +100,9 @@ _script_manager: ScriptManagerBase,
// channels in this worker's origin
_broadcast_channels: std.DoublyLinkedList = .{},
// List of MessagePorts living in this worker's context.
_message_ports: std.DoublyLinkedList = .{},
// These fields represent the "Window"-like component of the WGS
_proto: *EventTarget,
_console: Console = .init,
@@ -115,6 +120,7 @@ _location: WorkerLocation,
_timers: Timers = .{},
pub const Type = union(enum) {
shared: *SharedWorkerGlobalScope,
dedicated: *DedicatedWorkerGlobalScope,
};
@@ -190,6 +196,14 @@ pub fn deinit(self: *WorkerGlobalScope) void {
browser.http_client.abortOwner(&self._http_owner);
// Close this worker's MessagePorts before the context dies: this severs
// entanglement with page-side ports (which may outlive us) and releases
// any still-queued messages.
while (self._message_ports.first) |node| {
const port: *MessagePort = @alignCast(@fieldParentPtr("_node", node));
port.close(); // removes from self._message_ports
}
self._identity.deinit();
self._script_manager.deinit();

View File

@@ -36,14 +36,19 @@ _proto: *Event,
_data: ?Data = null,
_origin: []const u8 = "",
_last_event_id: []const u8 = "",
_source: ?*Window = null,
_source: ?Source = null,
_ports: []const *MessagePort = &.{},
pub const Source = union(enum) {
window: *Window,
port: *MessagePort,
};
const MessageEventOptions = struct {
data: ?Data = null,
origin: ?[]const u8 = null,
lastEventId: ?[]const u8 = null,
source: ?*Window = null,
source: ?Source = null,
ports: []const *MessagePort = &.{},
};
@@ -124,18 +129,24 @@ pub fn getLastEventId(self: *const MessageEvent) []const u8 {
return self._last_event_id;
}
pub fn getSource(self: *const MessageEvent, exec: *js.Execution) ?Window.Access {
switch (exec.js.global) {
.frame => |frame| {
const source = self._source orelse return null;
return Window.Access.init(frame.window, source);
},
.worker => {
// source for worker should always be null
if (comptime IS_DEBUG) {
std.debug.assert(self._source == null);
}
return null;
const SourceAccess = union(enum) {
window: Window.Access,
port: *MessagePort,
};
pub fn getSource(self: *const MessageEvent, exec: *js.Execution) ?SourceAccess {
const source = self._source orelse return null;
switch (source) {
.port => |port| return .{ .port = port },
.window => |window| switch (exec.js.global) {
.frame => |frame| return .{ .window = Window.Access.init(frame.window, window) },
.worker => {
// a window source should never reach a worker context
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return null;
},
},
}
}