mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 17:55:59 -04:00
Merge pull request #2977 from lightpanda-io/SharedWorker
webapi: Add SharedWorker
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
8
src/browser/tests/shared_worker/counter-worker.js
Normal file
8
src/browser/tests/shared_worker/counter-worker.js
Normal 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 });
|
||||
};
|
||||
};
|
||||
8
src/browser/tests/shared_worker/echo-worker.js
Normal file
8
src/browser/tests/shared_worker/echo-worker.js
Normal 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] });
|
||||
};
|
||||
};
|
||||
10
src/browser/tests/shared_worker/listener-worker.js
Normal file
10
src/browser/tests/shared_worker/listener-worker.js
Normal 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();
|
||||
});
|
||||
137
src/browser/tests/shared_worker/shared_worker.html
Normal file
137
src/browser/tests/shared_worker/shared_worker.html
Normal 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>
|
||||
@@ -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]",
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
146
src/browser/webapi/SharedWorker.zig
Normal file
146
src/browser/webapi/SharedWorker.zig
Normal 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 });
|
||||
}
|
||||
407
src/browser/webapi/SharedWorkerGlobalScope.zig
Normal file
407
src/browser/webapi/SharedWorkerGlobalScope.zig
Normal 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, .{});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user