window.postMessage across frames

Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/160

Improves postMessage support, specifically for use across frames. This commit
also addresses a few other issues (identified while implementing this).

1 - Since macrotasks can schedule more macrotasks, we need to check the time-to-
next microtask after all microtasks have completed.

2 - frame's onload callback is triggered from the frame's context, but has to
    execute on the parents contet.
This commit is contained in:
Karl Seguin
2026-03-14 09:13:50 +08:00
parent 42bb2f3c58
commit f754773bf6
15 changed files with 145 additions and 66 deletions

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.3.tar.gz",
.hash = "v8-0.0.0-xddH6yx3BAAGD9jSoq_ttt_bk9MectTU44s_HZxxE5LD",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/34cb5f50227047c6cc1b2af73dad958c267f0a83.tar.gz",
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{

View File

@@ -91,25 +91,32 @@ pub fn runMicrotasks(self: *Browser) void {
self.env.runMicrotasks();
}
pub fn runMacrotasks(self: *Browser) !?u64 {
pub fn runMacrotasks(self: *Browser) !void {
const env = &self.env;
const time_to_next = try self.env.runMacrotasks();
try self.env.runMacrotasks();
env.pumpMessageLoop();
// either of the above could have queued more microtasks
env.runMicrotasks();
return time_to_next;
}
pub fn hasBackgroundTasks(self: *Browser) bool {
return self.env.hasBackgroundTasks();
}
pub fn waitForBackgroundTasks(self: *Browser) void {
self.env.waitForBackgroundTasks();
}
pub fn msToNextMacrotask(self: *Browser) ?u64 {
return self.env.msToNextMacrotask();
}
pub fn msTo(self: *Browser) bool {
return self.env.hasBackgroundTasks();
}
pub fn runIdleTasks(self: *const Browser) void {
self.env.runIdleTasks();
}

View File

@@ -709,11 +709,14 @@ pub fn scriptsCompletedLoading(self: *Page) void {
}
pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
blk: {
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
const entered = self.js.enter(&ls.handle_scope);
defer entered.exit();
blk: {
const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| {
log.err(.page, "iframe event init", .{ .err = err, .url = iframe._src });
break :blk;
@@ -722,6 +725,7 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src });
};
}
self.pendingLoadCompleted();
}

View File

@@ -401,7 +401,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
const ms_to_next_task = try browser.runMacrotasks();
try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
@@ -423,16 +423,16 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
std.debug.assert(http_client.intercepted == 0);
}
var ms: u64 = ms_to_next_task orelse blk: {
if (wait_ms - ms_remaining < 100) {
if (comptime builtin.is_test) {
return .done;
}
// Look, we want to exit ASAP, but we don't want
// to exit so fast that we've run none of the
// background jobs.
break :blk 50;
}
var ms = blk: {
// if (wait_ms - ms_remaining < 100) {
// if (comptime builtin.is_test) {
// return .done;
// }
// // Look, we want to exit ASAP, but we don't want
// // to exit so fast that we've run none of the
// // background jobs.
// break :blk 50;
// }
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
@@ -441,9 +441,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
break :blk 20;
}
// No http transfers, no cdp extra socket, no
// scheduled tasks, we're done.
return .done;
break :blk browser.msToNextMacrotask() orelse return .done;
};
if (ms > ms_remaining) {
@@ -470,9 +468,9 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run lowPriority tasks, so we
// minimize how long we'll poll for network I/O.
var ms_to_wait = @min(200, ms_to_next_task orelse 200);
// We should continue to run tasks, so we minimize how long
// we'll poll for network I/O.
var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back

View File

@@ -252,6 +252,10 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
return l.toLocal(global);
}
pub fn getIncumbent(self: *Context) *Page {
return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).page;
}
pub fn stringToPersistedFunction(
self: *Context,
function_body: []const u8,

View File

@@ -382,8 +382,7 @@ pub fn runMicrotasks(self: *Env) void {
}
}
pub fn runMacrotasks(self: *Env) !?u64 {
var ms_to_next_task: ?u64 = null;
pub fn runMacrotasks(self: *Env) !void {
for (self.contexts[0..self.context_count]) |ctx| {
if (comptime builtin.is_test == false) {
// I hate this comptime check as much as you do. But we have tests
@@ -398,13 +397,17 @@ pub fn runMacrotasks(self: *Env) !?u64 {
var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs);
defer entered.exit();
const ms = (try ctx.scheduler.run()) orelse continue;
if (ms_to_next_task == null or ms < ms_to_next_task.?) {
ms_to_next_task = ms;
}
try ctx.scheduler.run();
}
return ms_to_next_task;
}
pub fn msToNextMacrotask(self: *Env) ?u64 {
var next_task: u64 = std.math.maxInt(u64);
for (self.contexts[0..self.context_count]) |ctx| {
const candidate = ctx.scheduler.msToNextHigh() orelse continue;
next_task = @min(candidate, next_task);
}
return if (next_task == std.math.maxInt(u64)) null else next_task;
}
pub fn pumpMessageLoop(self: *const Env) void {

View File

@@ -74,9 +74,10 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
});
}
pub fn run(self: *Scheduler) !?u64 {
_ = try self.runQueue(&self.low_priority);
return self.runQueue(&self.high_priority);
pub fn run(self: *Scheduler) !void {
const now = milliTimestamp(.monotonic);
try self.runQueue(&self.low_priority, now);
try self.runQueue(&self.high_priority, now);
}
pub fn hasReadyTasks(self: *Scheduler) bool {
@@ -84,16 +85,23 @@ pub fn hasReadyTasks(self: *Scheduler) bool {
return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
}
fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
if (queue.count() == 0) {
return null;
}
pub fn msToNextHigh(self: *Scheduler) ?u64 {
const task = self.high_priority.peek() orelse return null;
const now = milliTimestamp(.monotonic);
if (task.run_at <= now) {
return 0;
}
return @intCast(task.run_at - now);
}
fn runQueue(self: *Scheduler, queue: *Queue, now: u64) !void {
if (queue.count() == 0) {
return;
}
while (queue.peek()) |*task_| {
if (task_.run_at > now) {
return @intCast(task_.run_at - now);
return;
}
var task = queue.remove();
if (comptime IS_DEBUG) {
@@ -114,7 +122,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
try self.low_priority.add(task);
}
}
return null;
return;
}
fn queueuHasReadyTask(queue: *Queue, now: u64) bool {

View File

@@ -12,7 +12,7 @@
testing.expectEqual('', $('#a0').href);
testing.expectEqual(testing.BASE_URL + 'element/anchor1.html', $('#a1').href);
testing.expectEqual(testing.ORIGIN + 'hello/world/anchor2.html', $('#a2').href);
testing.expectEqual(testing.ORIGIN + '/hello/world/anchor2.html', $('#a2').href);
testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href);
testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href);

View File

@@ -32,7 +32,7 @@
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
form.action = '/hello';
testing.expectEqual(testing.ORIGIN + 'hello', form.action)
testing.expectEqual(testing.ORIGIN + '/hello', form.action)
form.action = 'https://lightpanda.io/hello';
testing.expectEqual('https://lightpanda.io/hello', form.action)

View File

@@ -37,7 +37,7 @@
testing.expectEqual('test.png', img.getAttribute('src'));
img.src = '/absolute/path.png';
testing.expectEqual(testing.ORIGIN + 'absolute/path.png', img.src);
testing.expectEqual(testing.ORIGIN + '/absolute/path.png', img.src);
testing.expectEqual('/absolute/path.png', img.getAttribute('src'));
img.src = 'https://example.com/image.png';

View File

@@ -8,7 +8,7 @@
testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href);
l2.href = '/over/9000';
testing.expectEqual(testing.ORIGIN + 'over/9000', l2.href);
testing.expectEqual(testing.ORIGIN + '/over/9000', l2.href);
l2.crossOrigin = 'nope';
testing.expectEqual('anonymous', l2.crossOrigin);

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<iframe id="receiver"></iframe>
<script id="messages">
{
let reply = null;
window.addEventListener('message', (e) => {
console.warn('reply')
reply = e.data;
});
const iframe = $('#receiver');
iframe.src = 'support/message_receiver.html';
iframe.addEventListener('load', () => {
iframe.contentWindow.postMessage('ping', '*');
});
testing.eventually(() => {
testing.expectEqual('pong', reply.data);
testing.expectEqual(testing.ORIGIN, reply.origin);
});
}
</script>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<script>
window.addEventListener('message', (e) => {
console.warn('Frame Message', e.data);
if (e.data === 'ping') {
window.top.postMessage({data: 'pong', origin: e.origin}, '*');
}
});
</script>

View File

@@ -114,7 +114,7 @@
eventually: eventually,
IS_TEST_RUNNER: IS_TEST_RUNNER,
HOST: '127.0.0.1',
ORIGIN: 'http://127.0.0.1:9582/',
ORIGIN: 'http://127.0.0.1:9582',
BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/',
};
@@ -124,7 +124,7 @@
// seemless, namely around adapting paths/urls.
console.warn(`The page is not being executed in the test runner, certain behavior has been adjusted`);
window.testing.HOST = location.hostname;
window.testing.ORIGIN = location.origin + '/';
window.testing.ORIGIN = location.origin;
window.testing.BASE_URL = location.origin + '/src/browser/tests/';
window.addEventListener('load', testing.assertOk);
}

View File

@@ -66,6 +66,7 @@ _on_load: ?js.Function.Global = null,
_on_pageshow: ?js.Function.Global = null,
_on_popstate: ?js.Function.Global = null,
_on_error: ?js.Function.Global = null,
_on_message: ?js.Function.Global = null,
_on_unhandled_rejection: ?js.Function.Global = null, // TODO: invoke on error
_location: *Location,
_timer_id: u30 = 0,
@@ -208,6 +209,14 @@ pub fn setOnError(self: *Window, setter: ?FunctionSetter) void {
self._on_error = getFunctionFromSetter(setter);
}
pub fn getOnMessage(self: *const Window) ?js.Function.Global {
return self._on_message;
}
pub fn setOnMessage(self: *Window, setter: ?FunctionSetter) void {
self._on_message = getFunctionFromSetter(setter);
}
pub fn getOnUnhandledRejection(self: *const Window) ?js.Function.Global {
return self._on_unhandled_rejection;
}
@@ -369,19 +378,26 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons
// In a full implementation, we would validate the origin
_ = target_origin;
// postMessage queues a task (not a microtask), so use the scheduler
const arena = try page.getArena(.{ .debug = "Window.schedule" });
errdefer page.releaseArena(arena);
// self = the window that will get the message
// page = the context calling postMessage
const target_page = self._page;
const source_window = target_page.js.getIncumbent().window;
const origin = try self._location.getOrigin(page);
const arena = try target_page.getArena(.{ .debug = "Window.postMessage" });
errdefer target_page.releaseArena(arena);
// Origin should be the source window's origin (where the message came from)
const origin = try source_window._location.getOrigin(page);
const callback = try arena.create(PostMessageCallback);
callback.* = .{
.page = page,
.arena = arena,
.message = message,
.page = target_page,
.source = source_window,
.origin = try arena.dupe(u8, origin),
};
try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
try target_page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
.name = "postMessage",
.low_priority = false,
.finalizer = PostMessageCallback.cancelled,
@@ -702,6 +718,7 @@ const ScheduleCallback = struct {
const PostMessageCallback = struct {
page: *Page,
source: *Window,
arena: Allocator,
origin: []const u8,
message: js.Value.Temp,
@@ -712,7 +729,7 @@ const PostMessageCallback = struct {
fn cancelled(ctx: *anyopaque) void {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
self.page.releaseArena(self.arena);
self.deinit();
}
fn run(ctx: *anyopaque) !?u32 {
@@ -722,14 +739,17 @@ const PostMessageCallback = struct {
const page = self.page;
const window = page.window;
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = self.origin,
.source = window,
.bubbles = false,
.cancelable = false,
}, page)).asEvent();
try page._event_manager.dispatch(window.asEventTarget(), event);
const event_target = window.asEventTarget();
if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) {
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = self.origin,
.source = self.source,
.bubbles = false,
.cancelable = false,
}, page)).asEvent();
try page._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" });
}
return null;
}
@@ -783,6 +803,7 @@ pub const JsApi = struct {
pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{});
pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{});
pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{});
pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{});
pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{});
pub const fetch = bridge.function(Window.fetch, .{});
pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{});