Files
browser/src/cdp/domains/page.zig
Karl Seguin 94ce5edd20 Frames on the same origin share v8 data
Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/153

In some ways this is an extension of
https://github.com/lightpanda-io/browser/pull/1635 but it has more implications
with respect to correctness.

A js.Context wraps a v8::Context. One of the important thing it adds is the
identity_map so that, given a Zig instance we always return the same v8::Object.

But imagine code running in a frame. This frame has its own Context, and thus
its own identity_map. What happens when that frame does:

```js
window.top.frame_loaded = true;
```

From Zig's point of view, `Window.getTop` will return the correct Zig instance.
It will return the *Window references by the "root" page. When that instance is
passed to the bridge, we'll look for the v8::Object in the Context's
`identity_map` but wont' find it. The mapping exists in the root context
`identity_map`, but not within this frame. So we create a new v8::Object and now
our 1 zig instance has N v8::Objects for every page/frame that tries to access
it.

This breaks cross-frame scripting which should work, at least to some degree,
even when frames are on the same origin.

This commit adds a `js.Origin` which contains the `identity_map`, along with our
other `v8::Global` storage. The `Env` now contains a `*js.Origin` lookup,
mapping an origin string (e.g. lightpanda.io:443) to an *Origin. When a Page's
URL is changed, we call `self.js.setOrigin(new_url)` which will then either get
or create an origin from the Env's origin lookup map.

js.Origin is reference counted so that it remains valid so long as at least 1
frame references them.

There's some special handling for null-origins (i.e. about:blank). At the root,
null origins get a distinct/isolated Origin. For a frame, the parent's origin
is used.

Above, we talked about `identity_map`, but a `js.Context` has 8 other fields
to track v8 values, e.g. `global_objects`, `global_functions`,
`global_values_temp`, etc. These all must be shared by frames on the same
origin. So all of these have also been moved to js.Origin. They've also been
merged so that we now have 3 fields: `identity_map`, `globals` and `temps`.

Finally, when the origin of a context is changed, we set the v8::Context's
SecurityToken (to that origin). This is a key part of how v8 allows cross-
context access.
2026-03-11 08:43:40 +08:00

737 lines
25 KiB
Zig

// Copyright (C) 2023-2025 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 screenshot_png = @embedFile("screenshot.png");
const id = @import("../id.zig");
const log = @import("../../log.zig");
const js = @import("../../browser/js/js.zig");
const URL = @import("../../browser/URL.zig");
const Page = @import("../../browser/Page.zig");
const timestampF = @import("../../datetime.zig").timestamp;
const Notification = @import("../../Notification.zig");
const Allocator = std.mem.Allocator;
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
getFrameTree,
setLifecycleEventsEnabled,
addScriptToEvaluateOnNewDocument,
createIsolatedWorld,
navigate,
stopLoading,
close,
captureScreenshot,
getLayoutMetrics,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.enable => return cmd.sendResult(null, .{}),
.getFrameTree => return getFrameTree(cmd),
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
.createIsolatedWorld => return createIsolatedWorld(cmd),
.navigate => return navigate(cmd),
.stopLoading => return cmd.sendResult(null, .{}),
.close => return close(cmd),
.captureScreenshot => return captureScreenshot(cmd),
.getLayoutMetrics => return getLayoutMetrics(cmd),
}
}
const Frame = struct {
id: []const u8,
loaderId: []const u8,
url: []const u8,
domainAndRegistry: []const u8 = "",
securityOrigin: []const u8,
mimeType: []const u8 = "text/html",
adFrameStatus: struct {
adFrameType: []const u8 = "none",
} = .{},
secureContextType: []const u8,
crossOriginIsolatedContextType: []const u8 = "NotIsolated",
gatedAPIFeatures: [][]const u8 = &[0][]const u8{},
};
fn getFrameTree(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = bc.target_id orelse return error.TargetNotLoaded;
return cmd.sendResult(.{
.frameTree = .{
.frame = Frame{
.id = &target_id,
.securityOrigin = bc.security_origin,
.loaderId = "LID-0000000001",
.url = bc.getURL() orelse "about:blank",
.secureContextType = bc.secure_context_type,
},
},
}, .{});
}
fn setLifecycleEventsEnabled(cmd: anytype) !void {
const params = (try cmd.params(struct {
enabled: bool,
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
if (params.enabled == false) {
bc.lifecycleEventsDisable();
return cmd.sendResult(null, .{});
}
// Enable lifecycle events.
try bc.lifecycleEventsEnable();
// When we enable lifecycle events, we must dispatch events for all
// attached targets.
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
if (page._load_state == .complete) {
const frame_id = &id.toFrameId(page._frame_id);
const loader_id = &id.toLoaderId(page._req_id);
const now = timestampF(.monotonic);
try sendPageLifecycle(bc, "DOMContentLoaded", now, frame_id, loader_id);
try sendPageLifecycle(bc, "load", now, frame_id, loader_id);
const http_client = page._session.browser.http_client;
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
try sendPageLifecycle(bc, "networkAlmostIdle", now, frame_id, loader_id);
}
if (page._notified_network_idle.check(total_network_activity == 0)) {
try sendPageLifecycle(bc, "networkIdle", now, frame_id, loader_id);
}
}
return cmd.sendResult(null, .{});
}
// TODO: hard coded method
// With the command we receive a script we need to store and run for each new document.
// Note that the worldName refers to the name given to the isolated world.
fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
// const params = (try cmd.params(struct {
// source: []const u8,
// worldName: ?[]const u8 = null,
// includeCommandLineAPI: bool = false,
// runImmediately: bool = false,
// })) orelse return error.InvalidParams;
return cmd.sendResult(.{
.identifier = "1",
}, .{});
}
fn close(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = bc.target_id orelse return error.TargetNotLoaded;
// can't be null if we have a target_id
lp.assert(bc.session.page != null, "CDP.page.close null page", .{});
try cmd.sendResult(.{}, .{});
// Following code is similar to target.closeTarget
//
// could be null, created but never attached
if (bc.session_id) |session_id| {
// Inspector.detached event
try cmd.sendEvent("Inspector.detached", .{
.reason = "Render process gone.",
}, .{ .session_id = session_id });
// detachedFromTarget event
try cmd.sendEvent("Target.detachedFromTarget", .{
.targetId = target_id,
.sessionId = session_id,
.reason = "Render process gone.",
}, .{});
bc.session_id = null;
}
bc.session.removePage();
for (bc.isolated_worlds.items) |world| {
world.deinit();
}
bc.isolated_worlds.clearRetainingCapacity();
bc.target_id = null;
}
fn createIsolatedWorld(cmd: anytype) !void {
const params = (try cmd.params(struct {
frameId: []const u8,
worldName: []const u8,
grantUniveralAccess: bool = false,
})) orelse return error.InvalidParams;
if (!params.grantUniveralAccess) {
log.warn(.not_implemented, "Page.createIsolatedWorld", .{ .param = "grantUniveralAccess" });
// When grantUniveralAccess == false and the client attempts to resolve
// or otherwise access a DOM or other JS Object from another context that should fail.
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const js_context = try world.createContext(page);
return cmd.sendResult(.{ .executionContextId = js_context.id }, .{});
}
fn navigate(cmd: anytype) !void {
const params = (try cmd.params(struct {
url: [:0]const u8,
// referrer: ?[]const u8 = null,
// transitionType: ?[]const u8 = null, // TODO: enum
// frameId: ?[]const u8 = null,
// referrerPolicy: ?[]const u8 = null, // TODO: enum
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
// didn't create?
// const target_id = bc.target_id orelse return error.TargetIdNotLoaded;
// didn't attach?
if (bc.session_id == null) {
return error.SessionIdNotLoaded;
}
const session = bc.session;
var page = session.currentPage() orelse return error.PageNotLoaded;
if (page._load_state != .waiting) {
page = try session.replacePage();
}
const encoded_url = try URL.ensureEncoded(page.call_arena, params.url);
try page.navigate(encoded_url, .{
.reason = .address_bar,
.cdp_id = cmd.input.id,
.kind = .{ .push = null },
});
}
pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
bc.reset();
const frame_id = &id.toFrameId(event.frame_id);
const loader_id = &id.toLoaderId(event.req_id);
var cdp = bc.cdp;
const reason_: ?[]const u8 = switch (event.opts.reason) {
.anchor => "anchorClick",
.script, .history, .navigation => "scriptInitiated",
.form => switch (event.opts.method) {
.GET => "formSubmissionGet",
.POST => "formSubmissionPost",
else => unreachable,
},
.address_bar => null,
.initialFrameNavigation => "initialFrameNavigation",
};
if (reason_) |reason| {
if (event.opts.reason != .initialFrameNavigation) {
try cdp.sendEvent("Page.frameScheduledNavigation", .{
.frameId = frame_id,
.delay = 0,
.reason = reason,
.url = event.url,
}, .{ .session_id = session_id });
}
try cdp.sendEvent("Page.frameRequestedNavigation", .{
.frameId = frame_id,
.reason = reason,
.url = event.url,
.disposition = "currentTab",
}, .{ .session_id = session_id });
}
// frameStartedNavigating event
try cdp.sendEvent("Page.frameStartedNavigating", .{
.frameId = frame_id,
.url = event.url,
.loaderId = loader_id,
.navigationType = "differentDocument",
}, .{ .session_id = session_id });
// frameStartedLoading event
try cdp.sendEvent("Page.frameStartedLoading", .{
.frameId = frame_id,
}, .{ .session_id = session_id });
}
pub fn pageRemove(bc: anytype) !void {
// Clear all remote object mappings to prevent stale objectIds from being used
// after the context is destroy
bc.inspector_session.inspector.resetContextGroup();
// The main page is going to be removed, we need to remove contexts from other worlds first.
for (bc.isolated_worlds.items) |isolated_world| {
try isolated_world.removeContext();
}
}
pub fn pageCreated(bc: anytype, page: *Page) !void {
_ = bc.cdp.page_arena.reset(.{ .retain_with_limit = 1024 * 512 });
for (bc.isolated_worlds.items) |isolated_world| {
_ = try isolated_world.createContext(page);
}
// Only retain captured responses until a navigation event. In CDP term,
// this is called a "renderer" and the cache-duration can be controlled via
// the Network.configureDurableMessages message (which we don't support)
bc.captured_responses = .empty;
}
pub fn pageFrameCreated(bc: anytype, event: *const Notification.PageFrameCreated) !void {
const session_id = bc.session_id orelse return;
const cdp = bc.cdp;
const frame_id = &id.toFrameId(event.frame_id);
try cdp.sendEvent("Page.frameAttached", .{ .params = .{
.frameId = frame_id,
.parentFrameId = &id.toFrameId(event.parent_id),
} }, .{ .session_id = session_id });
if (bc.page_life_cycle_events) {
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.name = "init",
.frameId = frame_id,
.loaderId = &id.toLoaderId(event.frame_id),
.timestamp = event.timestamp,
}, .{ .session_id = session_id });
}
}
pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.PageNavigated) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
const frame_id = &id.toFrameId(event.frame_id);
const loader_id = &id.toLoaderId(event.req_id);
var cdp = bc.cdp;
// Drivers are sensitive to the order of events. Some more than others.
// The result for the Page.navigate seems like it _must_ come after
// the frameStartedLoading, but before any lifecycleEvent. So we
// unfortunately have to put the input_id ito the NavigateOpts which gets
// passed back into the notification.
if (event.opts.cdp_id) |input_id| {
try cdp.sendJSON(.{
.id = input_id,
.result = .{
.frameId = frame_id,
.loaderId = loader_id,
},
.sessionId = session_id,
});
}
if (bc.page_life_cycle_events) {
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.name = "init",
.frameId = frame_id,
.loaderId = loader_id,
.timestamp = event.timestamp,
}, .{ .session_id = session_id });
}
const reason_: ?[]const u8 = switch (event.opts.reason) {
.anchor => "anchorClick",
.script, .history, .navigation => "scriptInitiated",
.form => switch (event.opts.method) {
.GET => "formSubmissionGet",
.POST => "formSubmissionPost",
else => unreachable,
},
.address_bar => null,
.initialFrameNavigation => "initialFrameNavigation",
};
if (reason_ != null) {
try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
.frameId = frame_id,
}, .{ .session_id = session_id });
}
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
// When we actually recreated the context we should have the inspector send
// this event, see: resetContextGroup Sending this event will tell the
// client that the context ids they had are invalid and the context shouls
// be dropped The client will expect us to send new contextCreated events,
// such that the client has new id's for the active contexts.
// Only send executionContextsCleared for main frame navigations. For child
// frames (iframes), clearing all contexts would destroy the main frame's
// context, causing Puppeteer's page.evaluate()/page.content() to hang
// forever.
if (event.frame_id == page._frame_id) {
try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
}
{
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
bc.inspector_session.inspector.contextCreated(
&ls.local,
"",
page.origin orelse "",
aux_data,
true,
);
}
for (bc.isolated_worlds.items) |isolated_world| {
const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
// Calling contextCreated will assign a new Id to the context and send the contextCreated event
var ls: js.Local.Scope = undefined;
(isolated_world.context orelse continue).localScope(&ls);
defer ls.deinit();
bc.inspector_session.inspector.contextCreated(
&ls.local,
isolated_world.name,
"://",
aux_json,
false,
);
}
// frameNavigated event
try cdp.sendEvent("Page.frameNavigated", .{
.type = "Navigation",
.frame = Frame{
.id = frame_id,
.url = event.url,
.loaderId = loader_id,
.securityOrigin = bc.security_origin,
.secureContextType = bc.secure_context_type,
},
}, .{ .session_id = session_id });
// The DOM.documentUpdated event must be send after the frameNavigated one.
// chromedp client expects to receive the events is this order.
// see https://github.com/chromedp/chromedp/issues/1558
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
// domContentEventFired event
// TODO: partially hard coded
try cdp.sendEvent(
"Page.domContentEventFired",
.{ .timestamp = timestamp },
.{ .session_id = session_id },
);
// lifecycle DOMContentLoaded event
// TODO: partially hard coded
if (bc.page_life_cycle_events) {
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp,
.name = "DOMContentLoaded",
.frameId = frame_id,
.loaderId = loader_id,
}, .{ .session_id = session_id });
}
// loadEventFired event
try cdp.sendEvent(
"Page.loadEventFired",
.{ .timestamp = timestamp },
.{ .session_id = session_id },
);
// lifecycle DOMContentLoaded event
if (bc.page_life_cycle_events) {
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp,
.name = "load",
.frameId = frame_id,
.loaderId = loader_id,
}, .{ .session_id = session_id });
}
// frameStoppedLoading
return cdp.sendEvent("Page.frameStoppedLoading", .{
.frameId = frame_id,
}, .{ .session_id = session_id });
}
pub fn pageNetworkIdle(bc: anytype, event: *const Notification.PageNetworkIdle) !void {
return sendPageLifecycle(bc, "networkIdle", event.timestamp, &id.toFrameId(event.frame_id), &id.toLoaderId(event.req_id));
}
pub fn pageNetworkAlmostIdle(bc: anytype, event: *const Notification.PageNetworkAlmostIdle) !void {
return sendPageLifecycle(bc, "networkAlmostIdle", event.timestamp, &id.toFrameId(event.frame_id), &id.toLoaderId(event.req_id));
}
fn sendPageLifecycle(bc: anytype, name: []const u8, timestamp: u64, frame_id: []const u8, loader_id: []const u8) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
return bc.cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.name = name,
.frameId = frame_id,
.loaderId = loader_id,
.timestamp = timestamp,
}, .{ .session_id = session_id });
}
const LifecycleEvent = struct {
frameId: []const u8,
loaderId: ?[]const u8,
name: []const u8,
timestamp: u64,
};
const Viewport = struct {
x: f64,
y: f64,
width: f64,
height: f64,
scale: f64,
};
fn base64Encode(comptime input: []const u8) [std.base64.standard.Encoder.calcSize(input.len)]u8 {
const encoder = std.base64.standard.Encoder;
var buf: [encoder.calcSize(input.len)]u8 = undefined;
_ = encoder.encode(&buf, input);
return buf;
}
fn captureScreenshot(cmd: anytype) !void {
const Params = struct {
format: ?[]const u8 = "png",
quality: ?u8 = null,
clip: ?Viewport = null,
fromSurface: ?bool = false,
captureBeyondViewport: ?bool = false,
optimizeForSpeed: ?bool = false,
};
const params = try cmd.params(Params) orelse Params{};
const format = params.format orelse "png";
if (!std.mem.eql(u8, format, "png")) {
log.warn(.not_implemented, "Page.captureScreenshot params", .{ .format = format });
return cmd.sendError(-32000, "unsupported screenshot format.", .{});
}
if (params.quality != null) {
log.warn(.not_implemented, "Page.captureScreenshot params", .{ .quality = params.quality });
}
if (params.clip != null) {
log.warn(.not_implemented, "Page.captureScreenshot params", .{ .clip = params.clip });
}
if (params.fromSurface orelse false or params.captureBeyondViewport orelse false or params.optimizeForSpeed orelse false) {
log.warn(.not_implemented, "Page.captureScreenshot params", .{
.fromSurface = params.fromSurface,
.captureBeyondViewport = params.captureBeyondViewport,
.optimizeForSpeed = params.optimizeForSpeed,
});
}
return cmd.sendResult(.{
.data = base64Encode(screenshot_png),
}, .{});
}
fn getLayoutMetrics(cmd: anytype) !void {
const width = 1920;
const height = 1080;
return cmd.sendResult(.{
.layoutViewport = .{
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
},
.visualViewport = .{
.offsetX = 0,
.offsetY = 0,
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
.scale = 1,
.zoom = 1,
},
.contentSize = .{
.x = 0,
.y = 0,
.width = width,
.height = height,
},
.cssLayoutViewport = .{
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
},
.cssVisualViewport = .{
.offsetX = 0,
.offsetY = 0,
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
.scale = 1,
.zoom = 1,
},
.cssContentSize = .{
.x = 0,
.y = 0,
.width = width,
.height = height,
},
}, .{});
}
const testing = @import("../testing.zig");
test "cdp.page: getFrameTree" {
var ctx = testing.context();
defer ctx.deinit();
{
try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } }));
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
}
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
{
try ctx.processMessage(.{ .id = 11, .method = "Page.getFrameTree" });
try ctx.expectSentResult(.{
.frameTree = .{
.frame = .{
.id = "FID-000000000X",
.loaderId = "LID-0000000001",
.url = "http://127.0.0.1:9582/src/browser/tests/hi.html",
.domainAndRegistry = "",
.securityOrigin = bc.security_origin,
.mimeType = "text/html",
.adFrameStatus = .{
.adFrameType = "none",
},
.secureContextType = bc.secure_context_type,
.crossOriginIsolatedContextType = "NotIsolated",
.gatedAPIFeatures = [_][]const u8{},
},
},
}, .{ .id = 11 });
}
}
test "cdp.page: captureScreenshot" {
var ctx = testing.context();
defer ctx.deinit();
{
try ctx.processMessage(.{ .id = 10, .method = "Page.captureScreenshot", .params = .{ .format = "jpg" } });
try ctx.expectSentError(-32000, "unsupported screenshot format.", .{ .id = 10 });
}
{
try ctx.processMessage(.{ .id = 11, .method = "Page.captureScreenshot" });
try ctx.expectSentResult(.{
.data = base64Encode(screenshot_png),
}, .{ .id = 11 });
}
}
test "cdp.page: getLayoutMetrics" {
var ctx = testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
const width = 1920;
const height = 1080;
try ctx.processMessage(.{ .id = 12, .method = "Page.getLayoutMetrics" });
try ctx.expectSentResult(.{
.layoutViewport = .{
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
},
.visualViewport = .{
.offsetX = 0,
.offsetY = 0,
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
.scale = 1,
.zoom = 1,
},
.contentSize = .{
.x = 0,
.y = 0,
.width = width,
.height = height,
},
.cssLayoutViewport = .{
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
},
.cssVisualViewport = .{
.offsetX = 0,
.offsetY = 0,
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
.scale = 1,
.zoom = 1,
},
.cssContentSize = .{
.x = 0,
.y = 0,
.width = width,
.height = height,
},
}, .{ .id = 12 });
}