diff --git a/src/browser/browser.zig b/src/browser/browser.zig index ca37d30f..ac872bf2 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -20,6 +20,7 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const Dump = @import("dump.zig"); const Mime = @import("mime.zig").Mime; @@ -53,13 +54,10 @@ pub const user_agent = "Lightpanda/1.0"; pub const Browser = struct { env: *Env, app: *App, - session: ?*Session, + session: ?Session, allocator: Allocator, http_client: *http.Client, - session_pool: SessionPool, - page_arena: std.heap.ArenaAllocator, - - const SessionPool = std.heap.MemoryPool(Session); + page_arena: ArenaAllocator, pub fn init(app: *App) !Browser { const allocator = app.allocator; @@ -75,31 +73,27 @@ pub const Browser = struct { .session = null, .allocator = allocator, .http_client = &app.http_client, - .session_pool = SessionPool.init(allocator), - .page_arena = std.heap.ArenaAllocator.init(allocator), + .page_arena = ArenaAllocator.init(allocator), }; } pub fn deinit(self: *Browser) void { self.closeSession(); self.env.deinit(); - self.session_pool.deinit(); self.page_arena.deinit(); } pub fn newSession(self: *Browser, ctx: anytype) !*Session { self.closeSession(); - - const session = try self.session_pool.create(); + self.session = undefined; + const session = &self.session.?; try Session.init(session, self, ctx); - self.session = session; return session; } pub fn closeSession(self: *Browser) void { - if (self.session) |session| { + if (self.session) |*session| { session.deinit(); - self.session_pool.destroy(session); self.session = null; } } @@ -114,33 +108,16 @@ pub const Browser = struct { // You can create successively multiple pages for a session, but you must // deinit a page before running another one. pub const Session = struct { - state: SessionState, - executor: *Env.Executor, - inspector: Env.Inspector, - - app: *App, browser: *Browser, - // The arena is used only to bound the js env init b/c it leaks memory. - // see https://github.com/lightpanda-io/jsruntime-lib/issues/181 - // - // The arena is initialised with self.alloc allocator. - // all others Session deps use directly self.alloc and not the arena. - // The arena is also used in the BrowserContext - arena: std.heap.ArenaAllocator, + // Used to create our Inspector and in the BrowserContext. + arena: ArenaAllocator, - window: Window, - - // TODO move the shed/jar to the browser? + executor: Env.Executor, storage_shed: storage.Shed, cookie_jar: storage.CookieJar, - // arbitrary that we pass to the inspector, which the inspector will include - // in any response/event that it emits. - aux_data: ?[]const u8 = null, - page: ?Page = null, - http_client: *http.Client, // recipient of notification, passed as the first parameter to notify notify_ctx: *anyopaque, @@ -159,109 +136,45 @@ pub const Session = struct { // need to play a little game. const any_ctx: *anyopaque = if (@TypeOf(ctx) == void) @constCast(@ptrCast(&{})) else ctx; - const app = browser.app; - const allocator = app.allocator; + var executor = try browser.env.newExecutor(); + errdefer executor.deinit(); + + const allocator = browser.app.allocator; self.* = .{ - .app = app, - .aux_data = null, .browser = browser, + .executor = executor, .notify_ctx = any_ctx, - .inspector = undefined, .notify_func = ContextStruct.notify, - .http_client = browser.http_client, - .executor = undefined, + .arena = ArenaAllocator.init(allocator), .storage_shed = storage.Shed.init(allocator), - .arena = std.heap.ArenaAllocator.init(allocator), .cookie_jar = storage.CookieJar.init(allocator), - .window = Window.create(null, .{ .agent = user_agent }), - .state = .{ - .loop = app.loop, - .document = null, - .http_client = browser.http_client, - - // we'll set this immediately after - .cookie_jar = undefined, - - // nothing should be used on the state until we have a page - // at which point we'll set these fields - .renderer = undefined, - .url = undefined, - .arena = undefined, - }, }; - self.state.cookie_jar = &self.cookie_jar; - errdefer self.arena.deinit(); - - self.executor = try browser.env.startExecutor(Window, &self.state, self, .main); - errdefer browser.env.stopExecutor(self.executor); - self.inspector = try Env.Inspector.init(self.arena.allocator(), self.executor, ctx); - - self.microtaskLoop(); } fn deinit(self: *Session) void { - self.app.loop.resetZig(); if (self.page != null) { self.removePage(); } - self.inspector.deinit(); self.arena.deinit(); self.cookie_jar.deinit(); self.storage_shed.deinit(); - self.browser.env.stopExecutor(self.executor); - } - - fn microtaskLoop(self: *Session) void { - self.browser.runMicrotasks(); - self.app.loop.zigTimeout(1 * std.time.ns_per_ms, *Session, self, microtaskLoop); - } - - pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) ![]const u8 { - const self: *Session = @ptrCast(@alignCast(ctx)); - const page = &(self.page orelse return error.NoPage); - - log.debug("fetch module: specifier: {s}", .{specifier}); - // fetchModule is called within the context of processing a page. - // Use the page_arena for this, which has a more appropriate lifetime - // and which has more retained memory between sessions and pages. - const arena = self.browser.page_arena.allocator(); - return try page.fetchData( - arena, - specifier, - if (page.current_script) |s| s.src else null, - ); - } - - pub fn callInspector(self: *const Session, msg: []const u8) void { - self.inspector.send(msg); + self.executor.deinit(); } // NOTE: the caller is not the owner of the returned value, // the pointer on Page is just returned as a convenience - pub fn createPage(self: *Session, aux_data: ?[]const u8) !*Page { + pub fn createPage(self: *Session) !*Page { std.debug.assert(self.page == null); - _ = self.browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 }); + const page_arena = &self.browser.page_arena; + _ = page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 }); - self.page = Page.init(self); + self.page = undefined; const page = &self.page.?; + try Page.init(page, page_arena.allocator(), self); // start JS env log.debug("start new js scope", .{}); - self.state.arena = self.browser.page_arena.allocator(); - errdefer self.state.arena = undefined; - - try self.executor.startScope(&self.window); - - // load polyfills - try polyfill.load(self.arena.allocator(), self.executor); - - if (aux_data) |ad| { - self.aux_data = try self.arena.allocator().dupe(u8, ad); - } - - // inspector - self.contextCreated(page); return page; } @@ -269,20 +182,13 @@ pub const Session = struct { pub fn removePage(self: *Session) void { std.debug.assert(self.page != null); // Reset all existing callbacks. - self.app.loop.resetJS(); + self.browser.app.loop.resetJS(); + self.browser.app.loop.resetZig(); self.executor.endScope(); - - // TODO unload document: https://html.spec.whatwg.org/#unloading-documents - - self.window.replaceLocation(.{ .url = null }) catch |e| { - log.err("reset window location: {any}", .{e}); - }; + self.page = null; // clear netsurf memory arena. parser.deinit(); - self.state.arena = undefined; - - self.page = null; } pub fn currentPage(self: *Session) ?*Page { @@ -299,20 +205,15 @@ pub const Session = struct { // look like a leak if we navigate from page to page a lot. var buf: [1024]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&buf); - const url = try self.page.?.url.?.resolve(fba.allocator(), url_string); + const url = try self.page.?.url.resolve(fba.allocator(), url_string); self.removePage(); - var page = try self.createPage(null); + var page = try self.createPage(); return page.navigate(url, .{ .reason = .anchor, }); } - fn contextCreated(self: *Session, page: *Page) void { - log.debug("inspector context created", .{}); - self.inspector.contextCreated(self.executor, "", (page.origin() catch "://") orelse "://", self.aux_data, true); - } - fn notify(self: *const Session, notification: *const Notification) void { self.notify_func(self.notify_ctx, notification) catch |err| { log.err("notify {}: {}", .{ std.meta.activeTag(notification.*), err }); @@ -326,28 +227,64 @@ pub const Session = struct { // The page handle all its memory in an arena allocator. The arena is reseted // when end() is called. pub const Page = struct { - arena: Allocator, session: *Session, - doc: ?*parser.Document = null, + + // an arena with a lifetime for the entire duration of the page + arena: Allocator, + + // Gets injected into any WebAPI method that needs it + state: SessionState, + + // Serves are the root object of our JavaScript environment + window: Window, + + doc: ?*parser.Document, // The URL of the page - url: ?URL = null, + url: URL, - raw_data: ?[]const u8 = null, + raw_data: ?[]const u8, + + renderer: FlatRenderer, + + scope: *Env.Scope, // current_script is the script currently evaluated by the page. // current_script could by fetch module to resolve module's url to fetch. current_script: ?*const Script = null, - renderer: FlatRenderer, - - fn init(session: *Session) Page { - const arena = session.browser.page_arena.allocator(); - return .{ + fn init(self: *Page, arena: Allocator, session: *Session) !void { + const browser = session.browser; + self.* = .{ + .window = .{}, .arena = arena, + .doc = null, + .raw_data = null, + .url = URL.empty, .session = session, .renderer = FlatRenderer.init(arena), + .state = .{ + .arena = arena, + .document = null, + .url = &self.url, + .renderer = &self.renderer, + .loop = browser.app.loop, + .cookie_jar = &session.cookie_jar, + .http_client = browser.http_client, + }, + .scope = try session.executor.startScope(&self.window, &self.state, self), }; + + // load polyfills + try polyfill.load(self.arena, self.scope); + + self.microtaskLoop(); + } + + fn microtaskLoop(self: *Page) void { + const browser = self.session.browser; + browser.runMicrotasks(); + browser.app.loop.zigTimeout(1 * std.time.ns_per_ms, *Page, self, microtaskLoop); } // dump writes the page content into the given file. @@ -363,13 +300,23 @@ pub const Page = struct { try Dump.writeHTML(self.doc.?, out); } + pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) ![]const u8 { + const self: *Page = @ptrCast(@alignCast(ctx)); + + log.debug("fetch module: specifier: {s}", .{specifier}); + return try self.fetchData( + specifier, + if (self.current_script) |s| s.src else null, + ); + } + pub fn wait(self: *Page) !void { // try catch var try_catch: Env.TryCatch = undefined; - try_catch.init(self.session.executor); + try_catch.init(self.scope); defer try_catch.deinit(); - self.session.app.loop.run() catch |err| { + self.session.browser.app.loop.run() catch |err| { if (try try_catch.err(self.arena)) |msg| { log.info("wait error: {s}", .{msg}); return; @@ -380,16 +327,13 @@ pub const Page = struct { log.debug("wait: OK", .{}); } - fn origin(self: *const Page) !?[]const u8 { - const url = &(self.url orelse return null); + fn origin(self: *const Page) ![]const u8 { var arr: std.ArrayListUnmanaged(u8) = .{}; - try url.origin(arr.writer(self.arena)); + try self.url.origin(arr.writer(self.arena)); return arr.items; } // spec reference: https://html.spec.whatwg.org/#document-lifecycle - // - aux_data: extra data forwarded to the Inspector - // see Inspector.contextCreated pub fn navigate(self: *Page, request_url: URL, opts: NavigateOpts) !void { const arena = self.arena; const session = self.session; @@ -405,36 +349,38 @@ pub const Page = struct { // later in this function, with the final request url (since we might // redirect) self.url = request_url; - var url = &self.url.?; - session.app.telemetry.record(.{ .navigate = .{ + session.browser.app.telemetry.record(.{ .navigate = .{ .proxy = false, - .tls = std.ascii.eqlIgnoreCase(url.scheme(), "https"), + .tls = std.ascii.eqlIgnoreCase(request_url.scheme(), "https"), } }); // load the data - var request = try self.newHTTPRequest(.GET, url, .{ .navigation = true }); + var request = try self.newHTTPRequest(.GET, &self.url, .{ .navigation = true }); defer request.deinit(); session.notify(&.{ .page_navigate = .{ - .url = url, + .url = &self.url, .reason = opts.reason, .timestamp = timestamp(), } }); + session.notify(&.{ .context_created = .{ + .origin = try self.origin(), + } }); + var response = try request.sendSync(.{}); // would be different than self.url in the case of a redirect self.url = try URL.fromURI(arena, request.uri); - url = &self.url.?; const header = response.header; - try session.cookie_jar.populateFromResponse(&url.uri, &header); + try session.cookie_jar.populateFromResponse(&self.url.uri, &header); // TODO handle fragment in url. - try session.window.replaceLocation(.{ .url = try url.toWebApi(arena) }); + try self.window.replaceLocation(.{ .url = try self.url.toWebApi(arena) }); - log.info("GET {any} {d}", .{ url, header.status }); + log.info("GET {any} {d}", .{ self.url, header.status }); const content_type = header.get("content-type"); @@ -458,7 +404,7 @@ pub const Page = struct { } session.notify(&.{ .page_navigated = .{ - .url = url, + .url = &self.url, .timestamp = timestamp(), } }); } @@ -494,27 +440,18 @@ pub const Page = struct { // https://html.spec.whatwg.org/#reporting-document-loading-status // inject the URL to the document including the fragment. - try parser.documentSetDocumentURI(doc, self.url.?.raw); + try parser.documentSetDocumentURI(doc, self.url.raw); - const session = self.session; // TODO set the referrer to the document. - try session.window.replaceDocument(html_doc); - session.window.setStorageShelf( - try session.storage_shed.getOrPut((try self.origin()) orelse "null"), + try self.window.replaceDocument(html_doc); + self.window.setStorageShelf( + try self.session.storage_shed.getOrPut(try self.origin()), ); // https://html.spec.whatwg.org/#read-html - // inspector - session.contextCreated(self); - - { - // update the sessions state - const state = &session.state; - state.url = &self.url.?; - state.document = html_doc; - state.renderer = &self.renderer; - } + // update the sessions state + self.state.document = html_doc; // browse the DOM tree to retrieve scripts // TODO execute the synchronous scripts during the HTL parsing. @@ -612,7 +549,7 @@ pub const Page = struct { try parser.eventInit(loadevt, "load", .{}); _ = try parser.eventTargetDispatchEvent( - parser.toEventTarget(Window, &self.session.window), + parser.toEventTarget(Window, &self.window), loadevt, ); } @@ -651,7 +588,7 @@ pub const Page = struct { // TODO handle charset attribute const opt_text = try parser.nodeTextContent(parser.elementToNode(s.element)); if (opt_text) |text| { - try s.eval(self.arena, self.session, text); + try s.eval(self, text); return; } @@ -670,9 +607,10 @@ pub const Page = struct { // It resolves src using the page's uri. // If a base path is given, src is resolved according to the base first. // the caller owns the returned string - fn fetchData(self: *const Page, arena: Allocator, src: []const u8, base: ?[]const u8) ![]const u8 { + fn fetchData(self: *const Page, src: []const u8, base: ?[]const u8) ![]const u8 { log.debug("starting fetch {s}", .{src}); + const arena = self.arena; var res_src = src; // if a base path is given, we resolve src using base. @@ -682,7 +620,7 @@ pub const Page = struct { res_src = try std.fs.path.resolve(arena, &.{ _dir, src }); } } - var origin_url = &self.url.?; + var origin_url = &self.url; const url = try origin_url.resolve(arena, res_src); var request = try self.newHTTPRequest(.GET, &url, .{ @@ -716,19 +654,17 @@ pub const Page = struct { return arr.items; } - fn fetchScript(self: *const Page, s: *const Script) !void { - const arena = self.arena; - const body = try self.fetchData(arena, s.src, null); - try s.eval(arena, self.session, body); + fn fetchScript(self: *Page, s: *const Script) !void { + const body = try self.fetchData(s.src, null); + try s.eval(self, body); } fn newHTTPRequest(self: *const Page, method: http.Request.Method, url: *const URL, opts: storage.cookie.LookupOpts) !http.Request { - const session = self.session; - var request = try session.http_client.request(method, &url.uri); + var request = try self.state.http_client.request(method, &url.uri); errdefer request.deinit(); var arr: std.ArrayListUnmanaged(u8) = .{}; - try session.cookie_jar.forRequest(&url.uri, arr.writer(self.arena), opts); + try self.state.cookie_jar.forRequest(&url.uri, arr.writer(self.arena), opts); if (arr.items.len > 0) { try request.addHeader("Cookie", arr.items, .{}); @@ -832,24 +768,24 @@ pub const Page = struct { return .unknown; } - fn eval(self: Script, arena: Allocator, session: *Session, body: []const u8) !void { + fn eval(self: Script, page: *Page, body: []const u8) !void { var try_catch: Env.TryCatch = undefined; - try_catch.init(session.executor); + try_catch.init(page.scope); defer try_catch.deinit(); const res = switch (self.kind) { .unknown => return error.UnknownScript, - .javascript => session.executor.exec(body, self.src), - .module => session.executor.module(body, self.src), + .javascript => page.scope.exec(body, self.src), + .module => page.scope.module(body, self.src), } catch { - if (try try_catch.err(arena)) |msg| { + if (try try_catch.err(page.arena)) |msg| { log.info("eval script {s}: {s}", .{ self.src, msg }); } return FetchError.JsErr; }; if (builtin.mode == .Debug) { - const msg = try res.toString(arena); + const msg = try res.toString(page.arena); log.debug("eval script {s}: {s}", .{ self.src, msg }); } } diff --git a/src/browser/dom/mutation_observer.zig b/src/browser/dom/mutation_observer.zig index 8aac8f3b..e7e5be71 100644 --- a/src/browser/dom/mutation_observer.zig +++ b/src/browser/dom/mutation_observer.zig @@ -114,7 +114,7 @@ pub const MutationObserver = struct { } } - pub fn jsScopeEnd(self: *MutationObserver, _: anytype) void { + pub fn jsCallScopeEnd(self: *MutationObserver, _: anytype) void { const record = self.observed.items; if (record.len == 0) { return; diff --git a/src/browser/env.zig b/src/browser/env.zig index 67e7207e..64b227fb 100644 --- a/src/browser/env.zig +++ b/src/browser/env.zig @@ -25,6 +25,7 @@ pub const JsThis = Env.JsThis; pub const JsObject = Env.JsObject; pub const Callback = Env.Callback; pub const Env = js.Env(*SessionState, Interfaces{}); +pub const Global = @import("html/window.zig").Window; pub const SessionState = struct { loop: *Loop, diff --git a/src/browser/polyfill/fetch.zig b/src/browser/polyfill/fetch.zig index 9581881e..7866628a 100644 --- a/src/browser/polyfill/fetch.zig +++ b/src/browser/polyfill/fetch.zig @@ -17,7 +17,7 @@ test "Browser.fetch" { var runner = try testing.jsRunner(testing.tracking_allocator, .{}); defer runner.deinit(); - try @import("polyfill.zig").load(testing.allocator, runner.executor); + try @import("polyfill.zig").load(testing.allocator, runner.scope); try runner.testCases(&.{ .{ diff --git a/src/browser/polyfill/polyfill.zig b/src/browser/polyfill/polyfill.zig index 9e682ea2..5ac4a806 100644 --- a/src/browser/polyfill/polyfill.zig +++ b/src/browser/polyfill/polyfill.zig @@ -31,13 +31,13 @@ const modules = [_]struct { .{ .name = "polyfill-fetch", .source = @import("fetch.zig").source }, }; -pub fn load(allocator: Allocator, executor: *Env.Executor) !void { +pub fn load(allocator: Allocator, scope: *Env.Scope) !void { var try_catch: Env.TryCatch = undefined; - try_catch.init(executor); + try_catch.init(scope); defer try_catch.deinit(); for (modules) |m| { - const res = executor.exec(m.source, m.name) catch |err| { + const res = scope.exec(m.source, m.name) catch |err| { if (try try_catch.err(allocator)) |msg| { defer allocator.free(msg); log.err("load {s}: {s}", .{ m.name, msg }); diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index dc44e22b..173315f9 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -25,6 +25,7 @@ const Env = @import("../browser/env.zig").Env; const asUint = @import("../str/parser.zig").asUint; const Browser = @import("../browser/browser.zig").Browser; const Session = @import("../browser/browser.zig").Session; +const Inspector = @import("../browser/env.zig").Env.Inspector; const Incrementing = @import("../id.zig").Incrementing; const Notification = @import("../notification.zig").Notification; @@ -309,40 +310,51 @@ pub fn BrowserContext(comptime CDP_T: type) type { node_registry: Node.Registry, node_search_list: Node.Search.List, - isolated_world: ?IsolatedWorld(Env), + inspector: Inspector, + isolated_world: ?IsolatedWorld, const Self = @This(); fn init(self: *Self, id: []const u8, cdp: *CDP_T) !void { const allocator = cdp.allocator; + const session = try cdp.browser.newSession(self); + const arena = session.arena.allocator(); + + const inspector = try cdp.browser.env.newInspector(arena, self); + var registry = Node.Registry.init(allocator); errdefer registry.deinit(); - const session = try cdp.browser.newSession(self); self.* = .{ .id = id, .cdp = cdp, + .arena = arena, .target_id = null, .session_id = null, + .session = session, .security_origin = URL_BASE, .secure_context_type = "Secure", // TODO = enum .loader_id = LOADER_ID, - .session = session, - .arena = session.arena.allocator(), .page_life_cycle_events = false, // TODO; Target based value .node_registry = registry, .node_search_list = undefined, .isolated_world = null, + .inspector = inspector, }; self.node_search_list = Node.Search.List.init(allocator, &self.node_registry); } pub fn deinit(self: *Self) void { - if (self.isolated_world) |isolated_world| { - isolated_world.executor.endScope(); - self.cdp.browser.env.stopExecutor(isolated_world.executor); - self.isolated_world = null; + self.inspector.deinit(); + + // If the session has a page, we need to clear it first. The page + // context is always nested inside of the isolated world context, + // so we need to shutdown the page one first. + self.cdp.browser.closeSession(); + + if (self.isolated_world) |*world| { + world.deinit(); } self.node_registry.deinit(); self.node_search_list.deinit(); @@ -353,25 +365,25 @@ pub fn BrowserContext(comptime CDP_T: type) type { self.node_search_list.reset(); } - pub fn createIsolatedWorld( - self: *Self, - world_name: []const u8, - grant_universal_access: bool, - ) !void { - if (self.isolated_world != null) return error.CurrentlyOnly1IsolatedWorldSupported; + pub fn createIsolatedWorld(self: *Self) !void { + if (self.isolated_world != null) { + return error.CurrentlyOnly1IsolatedWorldSupported; + } - const executor = try self.cdp.browser.env.startExecutor(@import("../browser/html/window.zig").Window, &self.session.state, self.session, .isolated); - errdefer self.cdp.browser.env.stopExecutor(executor); - - // TBD should we endScope on removePage and re-startScope on createPage? - // Window will be refactored into the executor so we leave it ugly here for now as a reminder. - try executor.startScope(@import("../browser/html/window.zig").Window{}); + var executor = try self.cdp.browser.env.newExecutor(); + errdefer executor.deinit(); self.isolated_world = .{ - .name = try self.arena.dupe(u8, world_name), - .grant_universal_access = grant_universal_access, + .name = "", + .global = .{}, + .scope = undefined, .executor = executor, + .grant_universal_access = false, }; + var world = &self.isolated_world.?; + + // TODO: can we do something better than passing `undefined` for the state? + world.scope = try world.executor.startScope(&world.global, undefined, {}); } pub fn nodeWriter(self: *Self, node: *const Node, opts: Node.Writer.Opts) Node.Writer { @@ -384,18 +396,35 @@ pub fn BrowserContext(comptime CDP_T: type) type { pub fn getURL(self: *const Self) ?[]const u8 { const page = self.session.currentPage() orelse return null; - return if (page.url) |*url| url.raw else null; + const raw_url = page.url.raw; + return if (raw_url.len == 0) null else raw_url; } pub fn notify(ctx: *anyopaque, notification: *const Notification) !void { const self: *Self = @alignCast(@ptrCast(ctx)); switch (notification.*) { + .context_created => |cc| { + const aux_data = try std.fmt.allocPrint(self.arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{self.target_id.?}); + self.inspector.contextCreated( + self.session.page.?.scope, + "", + cc.origin, + aux_data, + true, + ); + }, .page_navigate => |*pn| return @import("domains/page.zig").pageNavigate(self, pn), .page_navigated => |*pn| return @import("domains/page.zig").pageNavigated(self, pn), } } + pub fn callInspector(self: *const Self, msg: []const u8) void { + self.inspector.send(msg); + // force running micro tasks after send input to the inspector. + self.cdp.browser.runMicrotasks(); + } + pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { if (std.log.defaultLogEnabled(.debug)) { // msg should be {"id":,... @@ -481,13 +510,17 @@ pub fn BrowserContext(comptime CDP_T: type) type { /// An isolated world has it's own instance of globals like Window. /// Generally the client needs to resolve a node into the isolated world to be able to work with it. /// An object id is unique across all contexts, different object ids can refer to the same Node in different contexts. -pub fn IsolatedWorld(comptime E: type) type { - return struct { - name: []const u8, - grant_universal_access: bool, - executor: *E.Executor, - }; -} +const IsolatedWorld = struct { + name: []const u8, + scope: *Env.Scope, + executor: Env.Executor, + grant_universal_access: bool, + global: @import("../browser/html/window.zig").Window, + + pub fn deinit(self: *IsolatedWorld) void { + self.executor.deinit(); + } +}; // This is a generic because when we send a result we have two different // behaviors. Normally, we're sending the result to the client. But in some cases diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 0f594fd3..bab645b2 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -127,24 +127,27 @@ fn resolveNode(cmd: anytype) !void { objectGroup: ?[]const u8 = null, executionContextId: ?u32 = null, })) orelse return error.InvalidParams; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; - var executor = bc.session.executor; + var scope = page.scope; if (params.executionContextId) |context_id| { - if (executor.context.debugContextId() != context_id) { + if (scope.context.debugContextId() != context_id) { const isolated_world = bc.isolated_world orelse return error.ContextNotFound; - executor = isolated_world.executor; + scope = isolated_world.scope; - if (executor.context.debugContextId() != context_id) return error.ContextNotFound; + if (scope.context.debugContextId() != context_id) return error.ContextNotFound; } } - const input_node_id = if (params.nodeId) |node_id| node_id else params.backendNodeId orelse return error.InvalidParams; + + const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode; // node._node is a *parser.Node we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement // So we use the Node.Union when retrieve the value from the environment - const remote_object = try bc.session.inspector.getRemoteObject( - executor, + const remote_object = try bc.inspector.getRemoteObject( + scope, params.objectGroup orelse "", try dom_node.Node.toInterface(node._node), ); @@ -163,28 +166,61 @@ fn resolveNode(cmd: anytype) !void { fn describeNode(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: ?Node.Id = null, - backendNodeId: ?Node.Id = null, - objectId: ?[]const u8 = null, - depth: u32 = 1, - pierce: bool = false, + backendNodeId: ?u32 = null, + objectGroup: ?[]const u8 = null, + executionContextId: ?u32 = null, })) orelse return error.InvalidParams; - if (params.backendNodeId != null or params.depth != 1 or params.pierce) { + + if (params.nodeId == null or params.backendNodeId != null or params.executionContextId != null) { return error.NotYetImplementedParams; } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + const node = bc.node_registry.lookup_by_id.get(params.nodeId.?) orelse return error.UnknownNode; - if (params.nodeId != null) { - const node = bc.node_registry.lookup_by_id.get(params.nodeId.?) orelse return error.NodeNotFound; - return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{}) }, .{}); - } - if (params.objectId != null) { - // Retrieve the object from which ever context it is in. - const parser_node = try bc.session.inspector.getNodePtr(cmd.arena, params.objectId.?); - const node = try bc.node_registry.register(@ptrCast(parser_node)); - return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{}) }, .{}); - } - return error.MissingParams; + // node._node is a *parser.Node we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement + // So we use the Node.Union when retrieve the value from the environment + const remote_object = try bc.inspector.getRemoteObject( + page.scope, + params.objectGroup orelse "", + try dom_node.Node.toInterface(node._node), + ); + defer remote_object.deinit(); + + const arena = cmd.arena; + return cmd.sendResult(.{ .object = .{ + .type = try remote_object.getType(arena), + .subtype = try remote_object.getSubtype(arena), + .className = try remote_object.getClassName(arena), + .description = try remote_object.getDescription(arena), + .objectId = try remote_object.getObjectId(arena), + } }, .{}); + + // const params = (try cmd.params(struct { + // nodeId: ?Node.Id = null, + // backendNodeId: ?Node.Id = null, + // objectId: ?[]const u8 = null, + // depth: u32 = 1, + // pierce: bool = false, + // })) orelse return error.InvalidParams; + // if (params.backendNodeId != null or params.depth != 1 or params.pierce) { + // return error.NotYetImplementedParams; + // } + + // const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + + // if (params.nodeId != null) { + // const node = bc.node_registry.lookup_by_id.get(params.nodeId.?) orelse return error.NodeNotFound; + // return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{}) }, .{}); + // } + // if (params.objectId != null) { + // // Retrieve the object from which ever context it is in. + // const parser_node = try bc.session.inspector.getNodePtr(cmd.arena, params.objectId.?); + // const node = try bc.node_registry.register(@ptrCast(parser_node)); + // return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{}) }, .{}); + // } + // return error.MissingParams; } const testing = @import("../testing.zig"); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 2ef8b4f0..9ef65d6f 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -112,15 +112,15 @@ fn createIsolatedWorld(cmd: anytype) !void { } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess); const world = &bc.isolated_world.?; - + world.name = try bc.arena.dupe(u8, params.worldName); + world.grant_universal_access = params.grantUniveralAccess; // Create the auxdata json for the contextCreated event // Calling contextCreated will assign a Id to the context and send the contextCreated event const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId}); - bc.session.inspector.contextCreated(world.executor, world.name, "", aux_data, false); + bc.inspector.contextCreated(world.scope, world.name, "", aux_data, false); - return cmd.sendResult(.{ .executionContextId = world.executor.context.debugContextId() }, .{}); + return cmd.sendResult(.{ .executionContextId = world.scope.context.debugContextId() }, .{}); } fn navigate(cmd: anytype) !void { @@ -222,8 +222,8 @@ pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void const aux_json = try std.fmt.bufPrint(&buffer, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{bc.target_id.?}); // Calling contextCreated will assign a new Id to the context and send the contextCreated event - bc.session.inspector.contextCreated( - isolated_world.executor, + bc.inspector.contextCreated( + isolated_world.scope, isolated_world.name, "://", aux_json, diff --git a/src/cdp/domains/runtime.zig b/src/cdp/domains/runtime.zig index 8e8b1079..707b5912 100644 --- a/src/cdp/domains/runtime.zig +++ b/src/cdp/domains/runtime.zig @@ -44,10 +44,7 @@ fn sendInspector(cmd: anytype, action: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; // the result to return is handled directly by the inspector. - bc.session.callInspector(cmd.input.json); - - // force running micro tasks after send input to the inspector. - cmd.cdp.browser.runMicrotasks(); + bc.callInspector(cmd.input.json); } fn logInspector(cmd: anytype, action: anytype) !void { diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index 9380f9b5..4a34a489 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -122,14 +122,9 @@ fn createTarget(cmd: anytype) !void { const target_id = cmd.cdp.target_id_gen.next(); - // start the js env - const aux_data = try std.fmt.allocPrint( - cmd.arena, - // NOTE: we assume this is the default web page - "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", - .{target_id}, - ); - _ = try bc.session.createPage(aux_data); + try bc.createIsolatedWorld(); + + _ = try bc.session.createPage(); // change CDP state bc.security_origin = "://"; @@ -219,6 +214,10 @@ fn closeTarget(cmd: anytype) !void { } bc.session.removePage(); + if (bc.isolated_world) |*world| { + world.deinit(); + bc.isolated_world = null; + } bc.target_id = null; } @@ -520,10 +519,6 @@ test "cdp.target: createTarget" { { try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); try testing.expectEqual(true, bc.target_id != null); - try testing.expectEqual( - \\{"isDefault":true,"type":"default","frameId":"TID-1"} - , bc.session.aux_data); - try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); try ctx.expectSentEvent("Target.targetCreated", .{ .targetInfo = .{ .url = "about:blank", .title = "about:blank", .attached = false, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{}); } @@ -545,7 +540,7 @@ test "cdp.target: closeTarget" { } // pretend we createdTarget first - _ = try bc.session.createPage(null); + _ = try bc.session.createPage(); bc.target_id = "TID-A"; { try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } })); @@ -576,7 +571,7 @@ test "cdp.target: attachToTarget" { } // pretend we createdTarget first - _ = try bc.session.createPage(null); + _ = try bc.session.createPage(); bc.target_id = "TID-B"; { try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } })); @@ -620,7 +615,7 @@ test "cdp.target: getTargetInfo" { } // pretend we createdTarget first - _ = try bc.session.createPage(null); + _ = try bc.session.createPage(); bc.target_id = "TID-A"; { try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } })); diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 9ca7fc20..ef2aab87 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -122,7 +122,7 @@ const TestContext = struct { if (opts.html) |html| { parser.deinit(); try parser.init(); - const page = try bc.session.createPage(null); + const page = try bc.session.createPage(); page.doc = (try Document.init(html)).doc; } return bc; diff --git a/src/main.zig b/src/main.zig index 346fedb5..b0f5e1c3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -100,7 +100,7 @@ pub fn main() !void { var session = try browser.newSession({}); // page - const page = try session.createPage(null); + const page = try session.createPage(); _ = page.navigate(url, .{}) catch |err| switch (err) { error.UnsupportedUriScheme, error.UriMissingHost => { diff --git a/src/notification.zig b/src/notification.zig index 24310ae4..5d883590 100644 --- a/src/notification.zig +++ b/src/notification.zig @@ -4,6 +4,7 @@ const browser = @import("browser/browser.zig"); pub const Notification = union(enum) { page_navigate: PageNavigate, page_navigated: PageNavigated, + context_created: ContextCreated, pub const PageNavigate = struct { timestamp: u32, @@ -15,4 +16,8 @@ pub const Notification = union(enum) { timestamp: u32, url: *const URL, }; + + pub const ContextCreated = struct { + origin: []const u8, + }; }; diff --git a/src/runtime/js.zig b/src/runtime/js.zig index b861aa91..b53e0632 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -162,7 +162,7 @@ pub fn Env(comptime S: type, comptime types: anytype) type { global_scope: v8.HandleScope, // just kept around because we need to free it on deinit - isolate_params: v8.CreateParams, + isolate_params: *v8.CreateParams, // Given a type, we can lookup its index in TYPE_LOOKUP and then have // access to its TunctionTemplate (the thing we need to create an instance @@ -177,15 +177,9 @@ pub fn Env(comptime S: type, comptime types: anytype) type { // index. prototype_lookup: [Types.len]u16, - // Sessions are cheap, we mostly do this so we can get a stable pointer - executor_pool: std.heap.MemoryPool(Executor), - // Send a lowMemoryNotification whenever we stop an executor gc_hints: bool, - // Used in debug mode to assert that we only have one executor at a time - has_executor: if (builtin.mode == .Debug) bool else void = if (builtin.mode == .Debug) false else {}, - const Self = @This(); const State = S; @@ -196,11 +190,16 @@ pub fn Env(comptime S: type, comptime types: anytype) type { }; pub fn init(allocator: Allocator, opts: Opts) !*Self { - var params = v8.initCreateParams(); + // var params = v8.initCreateParams(); + var params = try allocator.create(v8.CreateParams); + errdefer allocator.destroy(params); + + v8.c.v8__Isolate__CreateParams__CONSTRUCT(params); + params.array_buffer_allocator = v8.createDefaultArrayBufferAllocator(); errdefer v8.destroyArrayBufferAllocator(params.array_buffer_allocator.?); - var isolate = v8.Isolate.init(¶ms); + var isolate = v8.Isolate.init(params); errdefer isolate.deinit(); isolate.enter(); @@ -221,7 +220,6 @@ pub fn Env(comptime S: type, comptime types: anytype) type { .gc_hints = opts.gc_hints, .global_scope = global_scope, .prototype_lookup = undefined, - .executor_pool = std.heap.MemoryPool(Executor).init(allocator), }; // Populate our templates lookup. generateClass creates the @@ -230,7 +228,7 @@ pub fn Env(comptime S: type, comptime types: anytype) type { // we can get its index via: @field(TYPE_LOOKUP, type_name).index const templates = &env.templates; inline for (Types, 0..) |s, i| { - templates[i] = env.generateClass(@field(types, s.name)); + templates[i] = generateClass(@field(types, s.name), isolate); } // Above, we've created all our our FunctionTemplates. Now that we @@ -259,160 +257,819 @@ pub fn Env(comptime S: type, comptime types: anytype) type { self.global_scope.deinit(); self.isolate.exit(); self.isolate.deinit(); - self.executor_pool.deinit(); v8.destroyArrayBufferAllocator(self.isolate_params.array_buffer_allocator.?); + self.allocator.destroy(self.isolate_params); self.allocator.destroy(self); } + pub fn newInspector(self: *Self, arena: Allocator, ctx: anytype) !Inspector { + return Inspector.init(arena, self.isolate, ctx); + } + pub fn runMicrotasks(self: *const Self) void { self.isolate.performMicrotasksCheckpoint(); } - pub fn startExecutor(self: *Self, comptime Global: type, state: State, module_loader: anytype, kind: WorldKind) !*Executor { - if (comptime builtin.mode == .Debug) { - if (kind == .main) { - std.debug.assert(self.has_executor == false); - self.has_executor = true; - } - } - const isolate = self.isolate; - const templates = &self.templates; - - // Acts like an Arena. Most things V8 has to allocate from this point - // on will be tied to this handle_scope - which we deinit in - // stopExecutor - var handle_scope: v8.HandleScope = undefined; - v8.HandleScope.init(&handle_scope, isolate); - - // The global FunctionTemplate (i.e. Window). - const globals = v8.FunctionTemplate.initDefault(isolate); - - const global_template = globals.getInstanceTemplate(); - global_template.setInternalFieldCount(1); - self.attachClass(Global, globals); - - // All the FunctionTemplates that we created and setup in Env.init - // are now going to get associated with our global instance. - inline for (Types, 0..) |s, i| { - const Struct = @field(types, s.name); - const class_name = v8.String.initUtf8(isolate, comptime classNameForStruct(Struct)); - global_template.set(class_name.toName(), templates[i], v8.PropertyAttribute.None); - } - - // The global object (Window) has already been hooked into the v8 - // engine when the Env was initialized - like every other type. But - // But the V8 global is its own FunctionTemplate instance so even - // though it's also a Window, we need to set the prototype for this - // specific instance of the the Window. - if (@hasDecl(Global, "prototype")) { - const proto_type = Receiver(@typeInfo(Global.prototype).pointer.child); - const proto_name = @typeName(proto_type); - const proto_index = @field(TYPE_LOOKUP, proto_name).index; - globals.inherit(templates[proto_index]); - } - - const context = v8.Context.init(isolate, global_template, null); - if (kind == .main) context.enter(); - errdefer if (kind == .main) context.exit(); - - // This shouldn't be necessary, but it is: - // https://groups.google.com/g/v8-users/c/qAQQBmbi--8 - // TODO: see if newer V8 engines have a way around this. - inline for (Types, 0..) |s, i| { - const Struct = @field(types, s.name); - - if (@hasDecl(Struct, "prototype")) { - const proto_type = Receiver(@typeInfo(Struct.prototype).pointer.child); - const proto_name = @typeName(proto_type); - if (@hasField(TypeLookup, proto_name) == false) { - @compileError("Type '" ++ @typeName(Struct) ++ "' defines an unknown prototype: " ++ proto_name); - } - - const proto_index = @field(TYPE_LOOKUP, proto_name).index; - const proto_obj = templates[proto_index].getFunction(context).toObject(); - - const self_obj = templates[i].getFunction(context).toObject(); - _ = self_obj.setPrototype(context, proto_obj); - } - } - - const executor = try self.executor_pool.create(); - errdefer self.executor_pool.destroy(executor); - - { - // Given a context, we can get our executor. - // (we store a pointer to our executor in the context's - // embeddeder data) - const data = isolate.initBigIntU64(@intCast(@intFromPtr(executor))); - context.setEmbedderData(1, data); - } - - executor.* = .{ - .state = state, - .isolate = isolate, - .kind = kind, - .context = context, - .templates = templates, - .handle_scope = handle_scope, - .call_arena = undefined, - .scope_arena = undefined, - ._call_arena_instance = std.heap.ArenaAllocator.init(self.allocator), - ._scope_arena_instance = std.heap.ArenaAllocator.init(self.allocator), - .module_loader = .{ - .ptr = @ptrCast(module_loader), - .func = @TypeOf(module_loader.*).fetchModuleSource, - }, + pub fn newExecutor(self: *Self) !Executor { + return .{ + .env = self, + .scope = null, + .call_arena = ArenaAllocator.init(self.allocator), + .scope_arena = ArenaAllocator.init(self.allocator), }; - - // We do it this way, just to present a slightly nicer API. Many - // things want one of these two allocator from the executor. Now they - // can do `executor.call_arena` instead of `executor.call_arena.allocator`. - executor.call_arena = executor._call_arena_instance.allocator(); - executor.scope_arena = executor._scope_arena_instance.allocator(); - - errdefer self.stopExecutor(executor); // Note: This likely has issues as context.exit() is errdefered as well - - // Custom exception - // NOTE: there is no way in v8 to subclass the Error built-in type - // TODO: this is an horrible hack - inline for (Types) |s| { - const Struct = @field(types, s.name); - if (@hasDecl(Struct, "ErrorSet")) { - const script = comptime classNameForStruct(Struct) ++ ".prototype.__proto__ = Error.prototype"; - _ = try executor.exec(script, "errorSubclass"); - } - } - - return executor; } - // In startExecutor we started a V8.Context. Here, we're going to - // deinit it. But V8 doesn't immediately free memory associated with - // a Context, it's managed by the garbage collector. So, when the - // `gc_hints` option is enabled, we'll use the `lowMemoryNotification` - // call on the isolate to encourage v8 to free the context. - pub fn stopExecutor(self: *Self, executor: *Executor) void { - if (comptime builtin.mode == .Debug) { - if (executor.kind == .main) { - std.debug.assert(self.has_executor == true); - self.has_executor = false; + pub const Executor = struct { + env: *Self, + + // Arena whose lifetime is for a single getter/setter/function/etc. + // Largely used to get strings out of V8, like a stack trace from + // a TryCatch. The allocator will be owned by the Scope, but the + // arena itself is owned by the Executor so that we can re-use it + // from scope to scope. + call_arena: ArenaAllocator, + + // Arena whose lifetime is for a single page load, aka a Scope. Where + // the call_arena lives for a single function call, the scope_arena + // lives for the lifetime of the entire page. The allocator will be + // owned by the Scope, but the arena itself is owned by the Executor + // so that we can re-use it from scope to scope. + scope_arena: ArenaAllocator, + + // A Scope maps to a Browser's Page. Here though, it's only a + // mechanism to organization page-specific memory. The Executor + // does all the work, but having all page-specific data structures + // grouped together helps keep things clean. + scope: ?Scope = null, + + // no init, must be initialized via env.newExecutor() + + pub fn deinit(self: *Executor) void { + if (self.scope != null) { + self.endScope(); + } + self.call_arena.deinit(); + self.scope_arena.deinit(); + + // V8 doesn't immediately free memory associated with + // a Context, it's managed by the garbage collector. So, when the + // `gc_hints` option is enabled, we'll use the `lowMemoryNotification` + // call on the isolate to encourage v8 to free any contexts which + // have been freed. + if (self.env.gc_hints) { + self.env.isolate.lowMemoryNotification(); } } - executor.deinit(); - self.executor_pool.destroy(executor); - if (self.gc_hints) { - self.isolate.lowMemoryNotification(); + // Our scope maps to a "browser.Page". + // A v8.HandleScope is like an arena. Once created, any "Local" that + // v8 creates will be released (or at least, releasable by the v8 GC) + // when the handle_scope is freed. + // We also maintain our own "scope_arena" which allows us to have + // all page related memory easily managed. + pub fn startScope(self: *Executor, global: anytype, state: State, module_loader: anytype) !*Scope { + std.debug.assert(self.scope == null); + + const ModuleLoader = switch (@typeInfo(@TypeOf(module_loader))) { + .@"struct" => @TypeOf(module_loader), + .pointer => |ptr| ptr.child, + .void => ErrorModuleLoader, + else => @compileError("invalid module_loader"), + }; + + // If necessary, turn a void context into something we can safely ptrCast + const safe_module_loader: *anyopaque = if (ModuleLoader == ErrorModuleLoader) @constCast(@ptrCast(&{})) else module_loader; + + const env = self.env; + const isolate = env.isolate; + const Global = @TypeOf(global.*); + + const js_global = v8.FunctionTemplate.initDefault(isolate); + attachClass(Global, isolate, js_global); + + const global_template = js_global.getInstanceTemplate(); + global_template.setInternalFieldCount(1); + + // All the FunctionTemplates that we created and setup in Env.init + // are now going to get associated with our global instance. + const templates = &self.env.templates; + inline for (Types, 0..) |s, i| { + const Struct = @field(types, s.name); + const class_name = v8.String.initUtf8(isolate, comptime classNameForStruct(Struct)); + global_template.set(class_name.toName(), templates[i], v8.PropertyAttribute.None); + } + + // The global object (Window) has already been hooked into the v8 + // engine when the Env was initialized - like every other type. + // But the V8 global is its own FunctionTemplate instance so even + // though it's also a Window, we need to set the prototype for this + // specific instance of the the Window. + if (@hasDecl(Global, "prototype")) { + const proto_type = Receiver(@typeInfo(Global.prototype).pointer.child); + const proto_name = @typeName(proto_type); + const proto_index = @field(TYPE_LOOKUP, proto_name).index; + js_global.inherit(templates[proto_index]); + } + + var handle_scope: v8.HandleScope = undefined; + v8.HandleScope.init(&handle_scope, isolate); + errdefer handle_scope.deinit(); + + const context = v8.Context.init(isolate, global_template, null); + context.enter(); + errdefer context.exit(); + + // This shouldn't be necessary, but it is: + // https://groups.google.com/g/v8-users/c/qAQQBmbi--8 + // TODO: see if newer V8 engines have a way around this. + inline for (Types, 0..) |s, i| { + const Struct = @field(types, s.name); + + if (@hasDecl(Struct, "prototype")) { + const proto_type = Receiver(@typeInfo(Struct.prototype).pointer.child); + const proto_name = @typeName(proto_type); + if (@hasField(TypeLookup, proto_name) == false) { + @compileError("Type '" ++ @typeName(Struct) ++ "' defines an unknown prototype: " ++ proto_name); + } + + const proto_index = @field(TYPE_LOOKUP, proto_name).index; + const proto_obj = templates[proto_index].getFunction(context).toObject(); + + const self_obj = templates[i].getFunction(context).toObject(); + _ = self_obj.setPrototype(context, proto_obj); + } + } + + self.scope = Scope{ + .state = state, + .isolate = isolate, + .context = context, + .templates = &env.templates, + .handle_scope = handle_scope, + .call_arena = self.call_arena.allocator(), + .scope_arena = self.scope_arena.allocator(), + .module_loader = .{ + .ptr = safe_module_loader, + .func = ModuleLoader.fetchModuleSource, + }, + }; + + var scope = &self.scope.?; + { + // Given a context, we can get our executor. + // (we store a pointer to our executor in the context's + // embeddeder data) + const data = isolate.initBigIntU64(@intCast(@intFromPtr(scope))); + context.setEmbedderData(1, data); + } + + // Custom exception + // NOTE: there is no way in v8 to subclass the Error built-in type + // TODO: this is an horrible hack + inline for (Types) |s| { + const Struct = @field(types, s.name); + if (@hasDecl(Struct, "ErrorSet")) { + const script = comptime classNameForStruct(Struct) ++ ".prototype.__proto__ = Error.prototype"; + _ = try scope.exec(script, "errorSubclass"); + } + } + + _ = try scope._mapZigInstanceToJs(context.getGlobal(), global); + return scope; } + + pub fn endScope(self: *Executor) void { + self.scope.?.deinit(); + self.scope = null; + _ = self.scope_arena.reset(.{ .retain_with_limit = SCOPE_ARENA_RETAIN }); + } + }; + + const PersistentObject = v8.Persistent(v8.Object); + const PersistentFunction = v8.Persistent(v8.Function); + + // Loosely maps to a Browser Page. + pub const Scope = struct { + state: State, + isolate: v8.Isolate, + context: v8.Context, + handle_scope: v8.HandleScope, + + // references the Env.template array + templates: []v8.FunctionTemplate, + + // An arena for the lifetime of a call-group. Gets reset whenever + // call_depth reaches 0. + call_arena: Allocator, + + // An arena for the lifetime of the scope + scope_arena: Allocator, + + // Because calls can be nested (i.e.a function calling a callback), + // we can only reset the call_arena when call_depth == 0. If we were + // to reset it within a callback, it would invalidate the data of + // the call which is calling the callback. + call_depth: usize = 0, + + // Callbacks are PesistendObjects. When the scope ends, we need + // to free every callback we created. + callbacks: std.ArrayListUnmanaged(v8.Persistent(v8.Function)) = .{}, + + // Serves two purposes. Like `callbacks` above, this is used to free + // every PeristentObjet we've created during the lifetime of the scope. + // More importantly, it serves as an identity map - for a given Zig + // instance, we map it to the same PersistentObject. + identity_map: std.AutoHashMapUnmanaged(usize, PersistentObject) = .{}, + + // When we need to load a resource (i.e. an external script), we call + // this function to get the source. This is always a reference to the + // Page's fetchModuleSource, but we use a function pointer + // since this js module is decoupled from the browser implementation. + module_loader: ModuleLoader, + + // Some Zig types have code to execute when the call scope ends + call_scope_end_callbacks: std.ArrayListUnmanaged(CallScopeEndCallback) = .{}, + + const ModuleLoader = struct { + ptr: *anyopaque, + func: *const fn (ptr: *anyopaque, specifier: []const u8) anyerror![]const u8, + }; + + // no init, started with executor.startScope() + + fn deinit(self: *Scope) void { + var it = self.identity_map.valueIterator(); + while (it.next()) |p| { + p.deinit(); + } + for (self.callbacks.items) |*cb| { + cb.deinit(); + } + self.context.exit(); + self.handle_scope.deinit(); + } + + fn trackCallback(self: *Scope, pf: PersistentFunction) !void { + return self.callbacks.append(self.scope_arena, pf); + } + + // Given an anytype, turns it into a v8.Object. The anytype could be: + // 1 - A V8.object already + // 2 - Our this JsObject wrapper around a V8.Object + // 3 - A zig instance that has previously been given to V8 + // (i.e., the value has to be known to the executor) + fn valueToExistingObject(self: *const Scope, value: anytype) !v8.Object { + if (@TypeOf(value) == v8.Object) { + return value; + } + + if (@TypeOf(value) == JsObject) { + return value.js_obj; + } + + const persistent_object = self.identity_map.get(@intFromPtr(value)) orelse { + return error.InvalidThisForCallback; + }; + + return persistent_object.castToObject(); + } + + // Executes the src + pub fn exec(self: *Scope, src: []const u8, name: ?[]const u8) !Value { + const isolate = self.isolate; + const context = self.context; + + var origin: ?v8.ScriptOrigin = null; + if (name) |n| { + const scr_name = v8.String.initUtf8(isolate, n); + origin = v8.ScriptOrigin.initDefault(self.isolate, scr_name.toValue()); + } + const scr_js = v8.String.initUtf8(isolate, src); + const scr = v8.Script.compile(context, scr_js, origin) catch { + return error.CompilationError; + }; + + const value = scr.run(context) catch { + return error.ExecutionError; + }; + + return self.createValue(value); + } + + // compile and eval a JS module + // It doesn't wait for callbacks execution + pub fn module(self: *Scope, src: []const u8, name: []const u8) !Value { + const context = self.context; + const m = try compileModule(self.isolate, src, name); + + // instantiate + // TODO handle ResolveModuleCallback parameters to load module's + // dependencies. + const ok = m.instantiate(context, resolveModuleCallback) catch { + return error.ExecutionError; + }; + + if (!ok) { + return error.ModuleInstantiationError; + } + + // evaluate + const value = m.evaluate(context) catch return error.ExecutionError; + return self.createValue(value); + } + + // Wrap a v8.Value, largely so that we can provide a convenient + // toString function + fn createValue(self: *const Scope, value: v8.Value) Value { + return .{ + .value = value, + .scope = self, + }; + } + + fn zigValueToJs(self: *const Scope, value: anytype) !v8.Value { + return Self.zigValueToJs(self.templates, self.isolate, self.context, value); + } + + // See _mapZigInstanceToJs, this is wrapper that can be called + // without a Scope. This is possible because we store our + // scope in the EmbedderData of the v8.Context. So, as long as + // we have a v8.Context, we can get the scope. + fn mapZigInstanceToJs(context: v8.Context, js_obj_or_template: anytype, value: anytype) !PersistentObject { + const scope: *Scope = @ptrFromInt(context.getEmbedderData(1).castTo(v8.BigInt).getUint64()); + return scope._mapZigInstanceToJs(js_obj_or_template, value); + } + + // To turn a Zig instance into a v8 object, we need to do a number of things. + // First, if it's a struct, we need to put it on the heap + // Second, if we've already returned this instance, we should return + // the same object. Hence, our executor maintains a map of Zig objects + // to v8.PersistentObject (the "identity_map"). + // Finally, if this is the first time we've seen this instance, we need to: + // 1 - get the FunctionTemplate (from our templates slice) + // 2 - Create the TaggedAnyOpaque so that, if needed, we can do the reverse + // (i.e. js -> zig) + // 3 - Create a v8.PersistentObject (because Zig owns this object, not v8) + // 4 - Store our TaggedAnyOpaque into the persistent object + // 5 - Update our identity_map (so that, if we return this same instance again, + // we can just grab it from the identity_map) + fn _mapZigInstanceToJs(self: *Scope, js_obj_or_template: anytype, value: anytype) !PersistentObject { + const context = self.context; + const scope_arena = self.scope_arena; + + const T = @TypeOf(value); + switch (@typeInfo(T)) { + .@"struct" => { + // Struct, has to be placed on the heap + const heap = try scope_arena.create(T); + heap.* = value; + return self._mapZigInstanceToJs(js_obj_or_template, heap); + }, + .pointer => |ptr| { + const gop = try self.identity_map.getOrPut(scope_arena, @intFromPtr(value)); + if (gop.found_existing) { + // we've seen this instance before, return the same + // PersistentObject. + return gop.value_ptr.*; + } + + if (comptime @hasDecl(ptr.child, "jsCallScopeEnd")) { + try self.call_scope_end_callbacks.append(scope_arena, CallScopeEndCallback.init(value)); + } + + // Sometimes we're creating a new v8.Object, like when + // we're returning a value from a function. In those cases + // we have the FunctionTemplate, and we can get an object + // by calling initInstance its InstanceTemplate. + // Sometimes though we already have the v8.Objct to bind to + // for example, when we're executing a constructor, v8 has + // already created the "this" object. + const js_obj = switch (@TypeOf(js_obj_or_template)) { + v8.Object => js_obj_or_template, + v8.FunctionTemplate => js_obj_or_template.getInstanceTemplate().initInstance(context), + else => @compileError("mapZigInstanceToJs requires a v8.Object (constructors) or v8.FunctionTemplate, got: " ++ @typeName(@TypeOf(js_obj_or_template))), + }; + + const isolate = self.isolate; + + if (isEmpty(ptr.child) == false) { + // The TAO contains the pointer ot our Zig instance as + // well as any meta data we'll need to use it later. + // See the TaggedAnyOpaque struct for more details. + const tao = try scope_arena.create(TaggedAnyOpaque); + const meta = @field(TYPE_LOOKUP, @typeName(ptr.child)); + tao.* = .{ + .ptr = value, + .index = meta.index, + .subtype = meta.subtype, + .offset = if (@typeInfo(ptr.child) != .@"opaque" and @hasField(ptr.child, "proto")) @offsetOf(ptr.child, "proto") else -1, + }; + + js_obj.setInternalField(0, v8.External.init(isolate, tao)); + } else { + // If the struct is empty, we don't need to do all + // the TOA stuff and setting the internal data. + // When we try to map this from JS->Zig, in + // typeTaggedAnyOpaque, we'll also know there that + // the type is empty and can create an empty instance. + } + + // Do not move this _AFTER_ the postAttach code. + // postAttach is likely to call back into this function + // mutating our identity_map, and making the gop pointers + // invalid. + const js_persistent = PersistentObject.init(isolate, js_obj); + gop.value_ptr.* = js_persistent; + + if (@hasDecl(ptr.child, "postAttach")) { + const obj_wrap = JsThis{ .obj = .{ .js_obj = js_obj, .scope = self } }; + switch (@typeInfo(@TypeOf(ptr.child.postAttach)).@"fn".params.len) { + 2 => try value.postAttach(obj_wrap), + 3 => try value.postAttach(self.state, obj_wrap), + else => @compileError(@typeName(ptr.child) ++ ".postAttach must take 2 or 3 parameters"), + } + } + + return js_persistent; + }, + else => @compileError("Expected a struct or pointer, got " ++ @typeName(T) ++ " (constructors must return struct or pointers)"), + } + } + + // Callback from V8, asking us to load a module. The "specifier" is + // the src of the module to load. + fn resolveModuleCallback( + c_context: ?*const v8.C_Context, + c_specifier: ?*const v8.C_String, + import_attributes: ?*const v8.C_FixedArray, + referrer: ?*const v8.C_Module, + ) callconv(.C) ?*const v8.C_Module { + _ = import_attributes; + _ = referrer; + + std.debug.assert(c_context != null); + const context = v8.Context{ .handle = c_context.? }; + + const self: *Scope = @ptrFromInt(context.getEmbedderData(1).castTo(v8.BigInt).getUint64()); + + var buf: [1024]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buf); + + // build the specifier value. + const specifier = valueToString( + fba.allocator(), + .{ .handle = c_specifier.? }, + self.isolate, + context, + ) catch |e| { + log.err("resolveModuleCallback: get ref str: {any}", .{e}); + return null; + }; + + // not currently needed + // const referrer_module = if (referrer) |ref| v8.Module{ .handle = ref } else null; + const module_loader = self.module_loader; + const source = module_loader.func(module_loader.ptr, specifier) catch |err| { + log.err("fetchModuleSource for '{s}' fetch error: {}", .{ specifier, err }); + return null; + }; + + const m = compileModule(self.isolate, source, specifier) catch |err| { + log.err("fetchModuleSource for '{s}' compile error: {}", .{ specifier, err }); + return null; + }; + return m.handle; + } + }; + + pub const Callback = struct { + id: usize, + scope: *Scope, + _this: ?v8.Object = null, + func: PersistentFunction, + + // We use this when mapping a JS value to a Zig object. We can't + // Say we have a Zig function that takes a Callback, we can't just + // check param.type == Callback, because Callback is a generic. + // So, as a quick hack, we can determine if the Zig type is a + // callback by checking @hasDecl(T, "_CALLBACK_ID_KLUDGE") + const _CALLBACK_ID_KLUDGE = true; + + pub const Result = struct { + stack: ?[]const u8, + exception: []const u8, + }; + + pub fn setThis(self: *Callback, value: anytype) !void { + self._this = try self.scope.valueToExistingObject(value); + } + + pub fn call(self: *const Callback, args: anytype) !void { + return self.callWithThis(self.getThis(), args); + } + + pub fn tryCall(self: *const Callback, args: anytype, result: *Result) !void { + return self.tryCallWithThis(self.getThis(), args, result); + } + + pub fn tryCallWithThis(self: *const Callback, this: anytype, args: anytype, result: *Result) !void { + var try_catch: TryCatch = undefined; + try_catch.init(self.scope); + defer try_catch.deinit(); + + self.callWithThis(this, args) catch |err| { + if (try_catch.hasCaught()) { + const allocator = self.scope.call_arena; + result.stack = try_catch.stack(allocator) catch null; + result.exception = (try_catch.exception(allocator) catch @errorName(err)) orelse @errorName(err); + } else { + result.stack = null; + result.exception = @errorName(err); + } + return err; + }; + } + + pub fn callWithThis(self: *const Callback, this: anytype, args: anytype) !void { + const scope = self.scope; + + const js_this = try scope.valueToExistingObject(this); + + const aargs = if (comptime @typeInfo(@TypeOf(args)) == .null) struct {}{} else args; + const fields = @typeInfo(@TypeOf(aargs)).@"struct".fields; + var js_args: [fields.len]v8.Value = undefined; + inline for (fields, 0..) |f, i| { + js_args[i] = try scope.zigValueToJs(@field(aargs, f.name)); + } + + const result = self.func.castToFunction().call(scope.context, js_this, &js_args); + if (result == null) { + return error.JSExecCallback; + } + } + + fn getThis(self: *const Callback) v8.Object { + return self._this orelse self.scope.context.getGlobal(); + } + + // debug/helper to print the source of the JS callback + fn printFunc(self: Callback) !void { + const scope = self.scope; + const value = self.func.castToFunction().toValue(); + const src = try valueToString(scope.call_arena, value, scope.isolate, scope.context); + std.debug.print("{s}\n", .{src}); + } + }; + + pub const JsObject = struct { + scope: *Scope, + js_obj: v8.Object, + + // If a Zig struct wants the Object parameter, it'll declare a + // function like: + // fn _length(self: *const NodeList, js_obj: Env.Object) usize + // + // When we're trying to call this function, we can't just do + // if (params[i].type.? == Object) + // Because there is _no_ object, there's only an Env.Object, where + // Env is a generic. + // We could probably figure out a way to do this, but simply checking + // for this declaration is _a lot_ easier. + const _JSOBJECT_ID_KLUDGE = true; + + pub fn setIndex(self: JsObject, index: usize, value: anytype) !void { + const key = switch (index) { + inline 0...1000 => |i| std.fmt.comptimePrint("{d}", .{i}), + else => try std.fmt.allocPrint(self.scope.scope_arena, "{d}", .{index}), + }; + return self.set(key, value); + } + + pub fn set(self: JsObject, key: []const u8, value: anytype) !void { + const scope = self.scope; + + const js_key = v8.String.initUtf8(scope.isolate, key); + const js_value = try scope.zigValueToJs(value); + if (!self.js_obj.setValue(scope.context, js_key, js_value)) { + return error.FailedToSet; + } + } + }; + + // This only exists so that we know whether a function wants the opaque + // JS argument (JsObject), or if it wants the receiver as an opaque + // value. + // JsObject is normally used when a method wants an opaque JS object + // that it'll pass into a callback. + // JsThis is used when the function wants to do advanced manipulation + // of the v8.Object bound to the instance. For example, postAttach is an + // example of using JsThis. + pub const JsThis = struct { + obj: JsObject, + + const _JSTHIS_ID_KLUDGE = true; + + pub fn setIndex(self: JsThis, index: usize, value: anytype) !void { + return self.obj.setIndex(index, value); + } + + pub fn set(self: JsThis, key: []const u8, value: anytype) !void { + return self.obj.set(key, value); + } + }; + + pub const TryCatch = struct { + inner: v8.TryCatch, + scope: *const Scope, + + pub fn init(self: *TryCatch, scope: *const Scope) void { + self.scope = scope; + self.inner.init(scope.isolate); + } + + pub fn hasCaught(self: TryCatch) bool { + return self.inner.hasCaught(); + } + + // the caller needs to deinit the string returned + pub fn exception(self: TryCatch, allocator: Allocator) !?[]const u8 { + const msg = self.inner.getException() orelse return null; + const scope = self.scope; + return try valueToString(allocator, msg, scope.isolate, scope.context); + } + + // the caller needs to deinit the string returned + pub fn stack(self: TryCatch, allocator: Allocator) !?[]const u8 { + const scope = self.scope; + const s = self.inner.getStackTrace(scope.context) orelse return null; + return try valueToString(allocator, s, scope.isolate, scope.context); + } + + // a shorthand method to return either the entire stack message + // or just the exception message + // - in Debug mode return the stack if available + // - otherwhise return the exception if available + // the caller needs to deinit the string returned + pub fn err(self: TryCatch, allocator: Allocator) !?[]const u8 { + if (builtin.mode == .Debug) { + if (try self.stack(allocator)) |msg| { + return msg; + } + } + return try self.exception(allocator); + } + + pub fn deinit(self: *TryCatch) void { + self.inner.deinit(); + } + }; + + pub const Inspector = struct { + isolate: v8.Isolate, + inner: *v8.Inspector, + session: v8.InspectorSession, + + // We expect allocator to be an arena + pub fn init(allocator: Allocator, isolate: v8.Isolate, ctx: anytype) !Inspector { + const ContextT = @TypeOf(ctx); + + const InspectorContainer = switch (@typeInfo(ContextT)) { + .@"struct" => ContextT, + .pointer => |ptr| ptr.child, + .void => NoopInspector, + else => @compileError("invalid context type"), + }; + + // If necessary, turn a void context into something we can safely ptrCast + const safe_context: *anyopaque = if (ContextT == void) @constCast(@ptrCast(&{})) else ctx; + + const channel = v8.InspectorChannel.init(safe_context, InspectorContainer.onInspectorResponse, InspectorContainer.onInspectorEvent, isolate); + + const client = v8.InspectorClient.init(); + + const inner = try allocator.create(v8.Inspector); + v8.Inspector.init(inner, client, channel, isolate); + return .{ .inner = inner, .isolate = isolate, .session = inner.connect() }; + } + + pub fn deinit(self: *const Inspector) void { + self.session.deinit(); + self.inner.deinit(); + } + + pub fn send(self: *const Inspector, msg: []const u8) void { + self.session.dispatchProtocolMessage(self.isolate, msg); + } + + // From CDP docs + // https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-ExecutionContextDescription + // ---- + // - name: Human readable name describing given context. + // - origin: Execution context origin (ie. URL who initialised the request) + // - auxData: Embedder-specific auxiliary data likely matching + // {isDefault: boolean, type: 'default'|'isolated'|'worker', frameId: string} + // - is_default_context: Whether the execution context is default, should match the auxData + pub fn contextCreated( + self: *const Inspector, + scope: *const Scope, + name: []const u8, + origin: []const u8, + aux_data: ?[]const u8, + is_default_context: bool, + ) void { + self.inner.contextCreated(scope.context, name, origin, aux_data, is_default_context); + } + + // Retrieves the RemoteObject for a given value. + // The value is loaded through the Executor's mapZigInstanceToJs function, + // just like a method return value. Therefore, if we've mapped this + // value before, we'll get the existing JS PersistedObject and if not + // we'll create it and track it for cleanup when the scope ends. + pub fn getRemoteObject( + self: *const Inspector, + scope: *const Scope, + group: []const u8, + value: anytype, + ) !RemoteObject { + const js_value = try zigValueToJs( + scope.templates, + scope.isolate, + scope.context, + value, + ); + + // We do not want to expose this as a parameter for now + const generate_preview = false; + return self.session.wrapObject( + scope.isolate, + scope.context, + js_value, + group, + generate_preview, + ); + } + + // Gets a value by object ID regardless of which context it is in. + pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []const u8) !?*anyopaque { + const unwrapped = try self.session.unwrapObject(allocator, object_id); + // The values context and groupId are not used here + const toa = getTaggedAnyOpaque(unwrapped.value) orelse return null; + if (toa.subtype == null or toa.subtype != .node) return error.ObjectIdIsNotANode; + return toa.ptr; + } + }; + + pub const RemoteObject = v8.RemoteObject; + + pub const Value = struct { + value: v8.Value, + scope: *const Scope, + + // the caller needs to deinit the string returned + pub fn toString(self: Value, allocator: Allocator) ![]const u8 { + const scope = self.scope; + return valueToString(allocator, self.value, scope.isolate, scope.context); + } + }; + + fn compileModule(isolate: v8.Isolate, src: []const u8, name: []const u8) !v8.Module { + // compile + const script_name = v8.String.initUtf8(isolate, name); + const script_source = v8.String.initUtf8(isolate, src); + + const origin = v8.ScriptOrigin.init( + isolate, + script_name.toValue(), + 0, // resource_line_offset + 0, // resource_column_offset + false, // resource_is_shared_cross_origin + -1, // script_id + null, // source_map_url + false, // resource_is_opaque + false, // is_wasm + true, // is_module + null, // host_defined_options + ); + + var script_comp_source: v8.ScriptCompilerSource = undefined; + v8.ScriptCompilerSource.init(&script_comp_source, script_source, origin, null); + defer script_comp_source.deinit(); + + return v8.ScriptCompiler.compileModule( + isolate, + &script_comp_source, + .kNoCompileOptions, + .kNoCacheNoReason, + ) catch return error.CompilationError; } // Give it a Zig struct, get back a v8.FunctionTemplate. // The FunctionTemplate is a bit like a struct container - it's where // we'll attach functions/getters/setters and where we'll "inherit" a // prototype type (if there is any) - fn generateClass(self: *Self, comptime Struct: type) v8.FunctionTemplate { - const template = self.generateConstructor(Struct); - self.attachClass(Struct, template); + fn generateClass(comptime Struct: type, isolate: v8.Isolate) v8.FunctionTemplate { + const template = generateConstructor(Struct, isolate); + attachClass(Struct, isolate, template); return template; } @@ -422,25 +1079,25 @@ pub fn Env(comptime S: type, comptime types: anytype) type { // But it's extracted from generateClass because we also have 1 global // object (i.e. the Window), which gets attached not only to the Window // constructor/FunctionTemplate as normal, but also through the default - // FunctionTemplate of the isolate (in startExecutor) - fn attachClass(self: *const Self, comptime Struct: type, template: v8.FunctionTemplate) void { + // FunctionTemplate of the isolate (in startScope) + fn attachClass(comptime Struct: type, isolate: v8.Isolate, template: v8.FunctionTemplate) void { const template_proto = template.getPrototypeTemplate(); inline for (@typeInfo(Struct).@"struct".decls) |declaration| { const name = declaration.name; if (comptime name[0] == '_') { switch (@typeInfo(@TypeOf(@field(Struct, name)))) { - .@"fn" => self.generateMethod(Struct, name, template_proto), - else => self.generateAttribute(Struct, name, template, template_proto), + .@"fn" => generateMethod(Struct, name, isolate, template_proto), + else => generateAttribute(Struct, name, isolate, template, template_proto), } } else if (comptime std.mem.startsWith(u8, name, "get_")) { - self.generateProperty(Struct, name[4..], template_proto); + generateProperty(Struct, name[4..], isolate, template_proto); } } if (@hasDecl(Struct, "get_symbol_toStringTag") == false) { // If this WAS defined, then we would have created it in generateProperty. // But if it isn't, we create a default one - const key = v8.Symbol.getToStringTag(self.isolate).toName(); + const key = v8.Symbol.getToStringTag(isolate).toName(); template_proto.setGetter(key, struct { fn stringTag(_: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) void { const info = v8.PropertyCallbackInfo.initFromV8(raw_info); @@ -450,8 +1107,8 @@ pub fn Env(comptime S: type, comptime types: anytype) type { }.stringTag); } - self.generateIndexer(Struct, template_proto); - self.generateNamedIndexer(Struct, template_proto); + generateIndexer(Struct, template_proto); + generateNamedIndexer(Struct, template_proto); } // Even if a struct doesn't have a `constructor` function, we still @@ -460,8 +1117,8 @@ pub fn Env(comptime S: type, comptime types: anytype) type { // via `new ClassName()` - but they could, for example, be created in // Zig and returned from a function call, which is why we need the // FunctionTemplate. - fn generateConstructor(self: *Self, comptime Struct: type) v8.FunctionTemplate { - const template = v8.FunctionTemplate.initCallback(self.isolate, struct { + fn generateConstructor(comptime Struct: type, isolate: v8.Isolate) v8.FunctionTemplate { + const template = v8.FunctionTemplate.initCallback(isolate, struct { fn callback(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void { const info = v8.FunctionCallbackInfo.initFromV8(raw_info); var caller = Caller(Self).init(info); @@ -473,8 +1130,8 @@ pub fn Env(comptime S: type, comptime types: anytype) type { // does `new ClassName()` where ClassName doesn't have // a constructor function, we'll return an error. if (@hasDecl(Struct, "constructor") == false) { - const isolate = caller.isolate; - const js_exception = isolate.throwException(createException(isolate, "illegal constructor")); + const iso = caller.isolate; + const js_exception = iso.throwException(createException(iso, "illegal constructor")); info.getReturnValue().set(js_exception); return; } @@ -494,19 +1151,19 @@ pub fn Env(comptime S: type, comptime types: anytype) type { template.getInstanceTemplate().setInternalFieldCount(1); } - const class_name = v8.String.initUtf8(self.isolate, comptime classNameForStruct(Struct)); + const class_name = v8.String.initUtf8(isolate, comptime classNameForStruct(Struct)); template.setClassName(class_name); return template; } - fn generateMethod(self: *const Self, comptime Struct: type, comptime name: []const u8, template_proto: v8.ObjectTemplate) void { + fn generateMethod(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template_proto: v8.ObjectTemplate) void { var js_name: v8.Name = undefined; if (comptime std.mem.eql(u8, name, "_symbol_iterator")) { - js_name = v8.Symbol.getIterator(self.isolate).toName(); + js_name = v8.Symbol.getIterator(isolate).toName(); } else { - js_name = v8.String.initUtf8(self.isolate, name[1..]).toName(); + js_name = v8.String.initUtf8(isolate, name[1..]).toName(); } - const function_template = v8.FunctionTemplate.initCallback(self.isolate, struct { + const function_template = v8.FunctionTemplate.initCallback(isolate, struct { fn callback(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void { const info = v8.FunctionCallbackInfo.initFromV8(raw_info); var caller = Caller(Self).init(info); @@ -521,11 +1178,11 @@ pub fn Env(comptime S: type, comptime types: anytype) type { template_proto.set(js_name, function_template, v8.PropertyAttribute.None); } - fn generateAttribute(self: *const Self, comptime Struct: type, comptime name: []const u8, template: v8.FunctionTemplate, template_proto: v8.ObjectTemplate) void { + fn generateAttribute(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template: v8.FunctionTemplate, template_proto: v8.ObjectTemplate) void { const zig_value = @field(Struct, name); - const js_value = simpleZigValueToJs(self.isolate, zig_value, true); + const js_value = simpleZigValueToJs(isolate, zig_value, true); - const js_name = v8.String.initUtf8(self.isolate, name[1..]).toName(); + const js_name = v8.String.initUtf8(isolate, name[1..]).toName(); // apply it both to the type itself template.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete); @@ -534,7 +1191,7 @@ pub fn Env(comptime S: type, comptime types: anytype) type { template_proto.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete); } - fn generateProperty(self: *const Self, comptime Struct: type, comptime name: []const u8, template_proto: v8.ObjectTemplate) void { + fn generateProperty(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template_proto: v8.ObjectTemplate) void { const getter = @field(Struct, "get_" ++ name); const param_count = @typeInfo(@TypeOf(getter)).@"fn".params.len; @@ -543,9 +1200,9 @@ pub fn Env(comptime S: type, comptime types: anytype) type { if (param_count != 0) { @compileError(@typeName(Struct) ++ ".get_symbol_toStringTag() cannot take any parameters"); } - js_name = v8.Symbol.getToStringTag(self.isolate).toName(); + js_name = v8.Symbol.getToStringTag(isolate).toName(); } else { - js_name = v8.String.initUtf8(self.isolate, name).toName(); + js_name = v8.String.initUtf8(isolate, name).toName(); } const getter_callback = struct { @@ -584,7 +1241,7 @@ pub fn Env(comptime S: type, comptime types: anytype) type { template_proto.setGetterAndSetter(js_name, getter_callback, setter_callback); } - fn generateIndexer(_: *const Self, comptime Struct: type, template_proto: v8.ObjectTemplate) void { + fn generateIndexer(comptime Struct: type, template_proto: v8.ObjectTemplate) void { if (@hasDecl(Struct, "indexed_get") == false) { return; } @@ -613,7 +1270,7 @@ pub fn Env(comptime S: type, comptime types: anytype) type { template_proto.setIndexedProperty(configuration, null); } - fn generateNamedIndexer(_: *const Self, comptime Struct: type, template_proto: v8.ObjectTemplate) void { + fn generateNamedIndexer(comptime Struct: type, template_proto: v8.ObjectTemplate) void { if (@hasDecl(Struct, "named_get") == false) { return; } @@ -685,7 +1342,7 @@ pub fn Env(comptime S: type, comptime types: anytype) type { const type_name = @typeName(ptr.child); if (@hasField(TypeLookup, type_name)) { const template = templates[@field(TYPE_LOOKUP, type_name).index]; - const js_obj = try Executor.mapZigInstanceToJs(context, template, value); + const js_obj = try Scope.mapZigInstanceToJs(context, template, value); return js_obj.toValue(); } @@ -721,7 +1378,7 @@ pub fn Env(comptime S: type, comptime types: anytype) type { const type_name = @typeName(T); if (@hasField(TypeLookup, type_name)) { const template = templates[@field(TYPE_LOOKUP, type_name).index]; - const js_obj = try Executor.mapZigInstanceToJs(context, template, value); + const js_obj = try Scope.mapZigInstanceToJs(context, template, value); return js_obj.toValue(); } @@ -780,714 +1437,6 @@ pub fn Env(comptime S: type, comptime types: anytype) type { @compileLog(@typeInfo(T)); @compileError("A function returns an unsupported type: " ++ @typeName(T)); } - - const PersistentObject = v8.Persistent(v8.Object); - const PersistentFunction = v8.Persistent(v8.Function); - - const WorldKind = enum { - main, - isolated, - worker, - }; - - // This is capable of executing JavaScript. - pub const Executor = struct { - state: State, - isolate: v8.Isolate, - kind: WorldKind, - - handle_scope: v8.HandleScope, - - // @intFromPtr of our Executor is stored in this context, so given - // a context, we can always get the Executor back. - context: v8.Context, - - // Because calls can be nested (i.e.a function calling a callback), - // we can only reset the call_arena when call_depth == 0. If we were - // to reset it within a callback, it would invalidate the data of - // the call which is calling the callback. - call_depth: usize = 0, - - // Arena whose lifetime is for a single getter/setter/function/etc. - // Largely used to get strings out of V8, like a stack trace from - // a TryCatch. The allocator will be owned by the Scope, but the - // arena itself is owned by the Executor so that we can re-use it - // from scope to scope. - call_arena: Allocator, - _call_arena_instance: std.heap.ArenaAllocator, - - // Arena whose lifetime is for a single page load, aka a Scope. Where - // the call_arena lives for a single function call, the scope_arena - // lives for the lifetime of the entire page. The allocator will be - // owned by the Scope, but the arena itself is owned by the Executor - // so that we can re-use it from scope to scope. - scope_arena: Allocator, - _scope_arena_instance: std.heap.ArenaAllocator, - - // When we need to load a resource (i.e. an external script), we call - // this function to get the source. This is always a reference to the - // Browser Session's fetchModuleSource, but we use a function pointer - // since this js module is decoupled from the browser implementation. - module_loader: ModuleLoader, - - // A Scope maps to a Browser's Page. Here though, it's only a - // mechanism to organization page-specific memory. The Executor - // does all the work, but having all page-specific data structures - // grouped together helps keep things clean. - scope: ?Scope = null, - - // refernces the Env.template array - templates: []v8.FunctionTemplate, - - const ModuleLoader = struct { ptr: *anyopaque, func: *const fn (ptr: *anyopaque, specifier: []const u8) anyerror![]const u8 }; - - // no init, must be initialized via env.startExecutor() - - // not public, must be destroyed via env.stopExecutor() - - fn deinit(self: *Executor) void { - if (self.scope != null) self.endScope(); - if (self.kind == .main) self.context.exit(); - self.handle_scope.deinit(); - - self._call_arena_instance.deinit(); - self._scope_arena_instance.deinit(); - } - - // Executes the src - pub fn exec(self: *Executor, src: []const u8, name: ?[]const u8) !Value { - const isolate = self.isolate; - const context = self.context; - - var origin: ?v8.ScriptOrigin = null; - if (name) |n| { - const scr_name = v8.String.initUtf8(isolate, n); - origin = v8.ScriptOrigin.initDefault(isolate, scr_name.toValue()); - } - const scr_js = v8.String.initUtf8(isolate, src); - const scr = v8.Script.compile(context, scr_js, origin) catch { - return error.CompilationError; - }; - - const value = scr.run(context) catch { - return error.ExecutionError; - }; - - return self.createValue(value); - } - - // compile and eval a JS module - // It doesn't wait for callbacks execution - pub fn module(self: *Executor, src: []const u8, name: []const u8) !Value { - const context = self.context; - const m = try self.compileModule(src, name); - - // instantiate - // TODO handle ResolveModuleCallback parameters to load module's - // dependencies. - const ok = m.instantiate(context, resolveModuleCallback) catch { - return error.ExecutionError; - }; - - if (!ok) { - return error.ModuleInstantiationError; - } - - // evaluate - const value = m.evaluate(context) catch return error.ExecutionError; - return self.createValue(value); - } - - fn compileModule(self: *Executor, src: []const u8, name: []const u8) !v8.Module { - const isolate = self.isolate; - - // compile - const script_name = v8.String.initUtf8(isolate, name); - const script_source = v8.String.initUtf8(isolate, src); - - const origin = v8.ScriptOrigin.init( - self.isolate, - script_name.toValue(), - 0, // resource_line_offset - 0, // resource_column_offset - false, // resource_is_shared_cross_origin - -1, // script_id - null, // source_map_url - false, // resource_is_opaque - false, // is_wasm - true, // is_module - null, // host_defined_options - ); - - var script_comp_source: v8.ScriptCompilerSource = undefined; - v8.ScriptCompilerSource.init(&script_comp_source, script_source, origin, null); - defer script_comp_source.deinit(); - - return v8.ScriptCompiler.compileModule( - isolate, - &script_comp_source, - .kNoCompileOptions, - .kNoCacheNoReason, - ) catch return error.CompilationError; - } - - // Our scope maps to a "browser.Page". - // A v8.HandleScope is like an arena. Once created, any "Local" that - // v8 creates will be released (or at least, releasable by the v8 GC) - // when the handle_scope is freed. - // We also maintain our own "scope_arena" which allows us to have - // all page related memory easily managed. - pub fn startScope(self: *Executor, global: anytype) !void { - std.debug.assert(self.scope == null); - - var handle_scope: v8.HandleScope = undefined; - v8.HandleScope.init(&handle_scope, self.isolate); - self.scope = Scope{ - .handle_scope = handle_scope, - .arena = self.scope_arena, - }; - _ = try self._mapZigInstanceToJs(self.context.getGlobal(), global); - } - - pub fn endScope(self: *Executor) void { - self.scope.?.deinit(); - self.scope = null; - _ = self._scope_arena_instance.reset(.{ .retain_with_limit = SCOPE_ARENA_RETAIN }); - } - - // Given an anytype, turns it into a v8.Object. The anytype could be: - // 1 - A V8.object already - // 2 - Our this JsObject wrapper around a V8.Object - // 3 - A zig instance that has previously been given to V8 - // (i.e., the value has to be known to the executor) - fn valueToExistingObject(self: *const Executor, value: anytype) !v8.Object { - if (@TypeOf(value) == v8.Object) { - return value; - } - - if (@TypeOf(value) == JsObject) { - return value.js_obj; - } - - const persistent_object = self.scope.?.identity_map.get(@intFromPtr(value)) orelse { - return error.InvalidThisForCallback; - }; - - return persistent_object.castToObject(); - } - - // Wrap a v8.Value, largely so that we can provide a convenient - // toString function - fn createValue(self: *const Executor, value: v8.Value) Value { - return .{ - .value = value, - .executor = self, - }; - } - - fn zigValueToJs(self: *const Executor, value: anytype) !v8.Value { - return Self.zigValueToJs(self.templates, self.isolate, self.context, value); - } - - // See _mapZigInstanceToJs, this is wrapper that can be called - // without an Executor. This is possible because we store our - // executor in the EmbedderData of the v8.Context. So, as long as - // we have a v8.Context, we can get the executor. - fn mapZigInstanceToJs(context: v8.Context, js_obj_or_template: anytype, value: anytype) !PersistentObject { - const executor: *Executor = @ptrFromInt(context.getEmbedderData(1).castTo(v8.BigInt).getUint64()); - return executor._mapZigInstanceToJs(js_obj_or_template, value); - } - - // To turn a Zig instance into a v8 object, we need to do a number of things. - // First, if it's a struct, we need to put it on the heap - // Second, if we've already returned this instance, we should return - // the same object. Hence, our executor maintains a map of Zig objects - // to v8.PersistentObject (the "identity_map"). - // Finally, if this is the first time we've seen this instance, we need to: - // 1 - get the FunctionTemplate (from our templates slice) - // 2 - Create the TaggedAnyOpaque so that, if needed, we can do the reverse - // (i.e. js -> zig) - // 3 - Create a v8.PersistentObject (because Zig owns this object, not v8) - // 4 - Store our TaggedAnyOpaque into the persistent object - // 5 - Update our identity_map (so that, if we return this same instance again, - // we can just grab it from the identity_map) - fn _mapZigInstanceToJs(self: *Executor, js_obj_or_template: anytype, value: anytype) !PersistentObject { - const scope = &self.scope.?; - const context = self.context; - const scope_arena = scope.arena; - - const T = @TypeOf(value); - switch (@typeInfo(T)) { - .@"struct" => { - // Struct, has to be placed on the heap - const heap = try scope_arena.create(T); - heap.* = value; - return self._mapZigInstanceToJs(js_obj_or_template, heap); - }, - .pointer => |ptr| { - const gop = try scope.identity_map.getOrPut(scope_arena, @intFromPtr(value)); - if (gop.found_existing) { - // we've seen this instance before, return the same - // PersistentObject. - return gop.value_ptr.*; - } - - if (comptime @hasDecl(ptr.child, "jsScopeEnd")) { - try scope.scope_end_callbacks.append(scope_arena, ScopeEndCallback.init(value)); - } - - // Sometimes we're creating a new v8.Object, like when - // we're returning a value from a function. In those cases - // we have the FunctionTemplate, and we can get an object - // by calling initInstance its InstanceTemplate. - // Sometimes though we already have the v8.Objct to bind to - // for example, when we're executing a constructor, v8 has - // already created the "this" object. - const js_obj = switch (@TypeOf(js_obj_or_template)) { - v8.Object => js_obj_or_template, - v8.FunctionTemplate => js_obj_or_template.getInstanceTemplate().initInstance(context), - else => @compileError("mapZigInstanceToJs requires a v8.Object (constructors) or v8.FunctionTemplate, got: " ++ @typeName(@TypeOf(js_obj_or_template))), - }; - - const isolate = self.isolate; - - if (isEmpty(ptr.child) == false) { - // The TAO contains the pointer ot our Zig instance as - // well as any meta data we'll need to use it later. - // See the TaggedAnyOpaque struct for more details. - const tao = try scope_arena.create(TaggedAnyOpaque); - const meta = @field(TYPE_LOOKUP, @typeName(ptr.child)); - tao.* = .{ - .ptr = value, - .index = meta.index, - .subtype = meta.subtype, - .offset = if (@typeInfo(ptr.child) != .@"opaque" and @hasField(ptr.child, "proto")) @offsetOf(ptr.child, "proto") else -1, - }; - - js_obj.setInternalField(0, v8.External.init(isolate, tao)); - } else { - // If the struct is empty, we don't need to do all - // the TOA stuff and setting the internal data. - // When we try to map this from JS->Zig, in - // typeTaggedAnyOpaque, we'll also know there that - // the type is empty and can create an empty instance. - } - - // Do not move this _AFTER_ the postAttach code. - // postAttach is likely to call back into this function - // mutating our identity_map, and making the gop pointers - // invalid. - const js_persistent = PersistentObject.init(isolate, js_obj); - gop.value_ptr.* = js_persistent; - - if (@hasDecl(ptr.child, "postAttach")) { - const obj_wrap = JsThis{ .obj = .{ .js_obj = js_obj, .executor = self } }; - switch (@typeInfo(@TypeOf(ptr.child.postAttach)).@"fn".params.len) { - 2 => try value.postAttach(obj_wrap), - 3 => try value.postAttach(self.state, obj_wrap), - else => @compileError(@typeName(ptr.child) ++ ".postAttach must take 2 or 3 parameters"), - } - } - - return js_persistent; - }, - else => @compileError("Expected a struct or pointer, got " ++ @typeName(T) ++ " (constructors must return struct or pointers)"), - } - } - - // Callback from V8, asking us to load a module. The "specifier" is - // the src of the module to load. - fn resolveModuleCallback( - c_context: ?*const v8.C_Context, - c_specifier: ?*const v8.C_String, - import_attributes: ?*const v8.C_FixedArray, - referrer: ?*const v8.C_Module, - ) callconv(.C) ?*const v8.C_Module { - _ = import_attributes; - _ = referrer; - - std.debug.assert(c_context != null); - const context = v8.Context{ .handle = c_context.? }; - - const self: *Executor = @ptrFromInt(context.getEmbedderData(1).castTo(v8.BigInt).getUint64()); - - var buf: [1024]u8 = undefined; - var fba = std.heap.FixedBufferAllocator.init(&buf); - - // build the specifier value. - const specifier = valueToString( - fba.allocator(), - .{ .handle = c_specifier.? }, - self.isolate, - context, - ) catch |e| { - log.err("resolveModuleCallback: get ref str: {any}", .{e}); - return null; - }; - - // not currently needed - // const referrer_module = if (referrer) |ref| v8.Module{ .handle = ref } else null; - const module_loader = self.module_loader; - const source = module_loader.func(module_loader.ptr, specifier) catch |err| { - log.err("fetchModuleSource for '{s}' fetch error: {}", .{ specifier, err }); - return null; - }; - - const m = self.compileModule(source, specifier) catch |err| { - log.err("fetchModuleSource for '{s}' compile error: {}", .{ specifier, err }); - return null; - }; - return m.handle; - } - }; - - // Loosely maps to a Browser Page. Executor does all the work, this just - // contains all the data structures / memory we need for a page. It helps - // to keep things organized. I.e. we have a single nullable, - // scope: ?Scope = null - // in executor, rather than having one for each of these. - pub const Scope = struct { - arena: Allocator, - handle_scope: v8.HandleScope, - scope_end_callbacks: std.ArrayListUnmanaged(ScopeEndCallback) = .{}, - callbacks: std.ArrayListUnmanaged(v8.Persistent(v8.Function)) = .{}, - identity_map: std.AutoHashMapUnmanaged(usize, PersistentObject) = .{}, - - fn deinit(self: *Scope) void { - var it = self.identity_map.valueIterator(); - while (it.next()) |p| { - p.deinit(); - } - for (self.callbacks.items) |*cb| { - cb.deinit(); - } - self.handle_scope.deinit(); - } - - fn trackCallback(self: *Scope, pf: PersistentFunction) !void { - return self.callbacks.append(self.arena, pf); - } - }; - - // An interface for types that want to their jsScopeEnd function to be - // called when the scope ends - const ScopeEndCallback = struct { - ptr: *anyopaque, - scopeEndFn: *const fn (ptr: *anyopaque, executor: *Executor) void, - - fn init(ptr: anytype) ScopeEndCallback { - const T = @TypeOf(ptr); - const ptr_info = @typeInfo(T); - - const gen = struct { - pub fn scopeEnd(pointer: *anyopaque, executor: *Executor) void { - const self: T = @ptrCast(@alignCast(pointer)); - return ptr_info.pointer.child.jsScopeEnd(self, executor); - } - }; - - return .{ - .ptr = ptr, - .scopeEndFn = gen.scopeEnd, - }; - } - - pub fn scopeEnd(self: ScopeEndCallback, executor: *Executor) void { - self.scopeEndFn(self.ptr, executor); - } - }; - - pub const Callback = struct { - id: usize, - executor: *Executor, - _this: ?v8.Object = null, - func: PersistentFunction, - - // We use this when mapping a JS value to a Zig object. We can't - // Say we have a Zig function that takes a Callback, we can't just - // check param.type == Callback, because Callback is a generic. - // So, as a quick hack, we can determine if the Zig type is a - // callback by checking @hasDecl(T, "_CALLBACK_ID_KLUDGE") - const _CALLBACK_ID_KLUDGE = true; - - pub const Result = struct { - stack: ?[]const u8, - exception: []const u8, - }; - - pub fn setThis(self: *Callback, value: anytype) !void { - self._this = try self.executor.valueToExistingObject(value); - } - - pub fn call(self: *const Callback, args: anytype) !void { - return self.callWithThis(self.getThis(), args); - } - - pub fn tryCall(self: *const Callback, args: anytype, result: *Result) !void { - return self.tryCallWithThis(self.getThis(), args, result); - } - - pub fn tryCallWithThis(self: *const Callback, this: anytype, args: anytype, result: *Result) !void { - var try_catch: TryCatch = undefined; - try_catch.init(self.executor); - defer try_catch.deinit(); - - self.callWithThis(this, args) catch |err| { - if (try_catch.hasCaught()) { - const allocator = self.executor.call_arena; - result.stack = try_catch.stack(allocator) catch null; - result.exception = (try_catch.exception(allocator) catch @errorName(err)) orelse @errorName(err); - } else { - result.stack = null; - result.exception = @errorName(err); - } - return err; - }; - } - - pub fn callWithThis(self: *const Callback, this: anytype, args: anytype) !void { - const executor = self.executor; - - const js_this = try executor.valueToExistingObject(this); - - const aargs = if (comptime @typeInfo(@TypeOf(args)) == .null) struct {}{} else args; - const fields = @typeInfo(@TypeOf(aargs)).@"struct".fields; - var js_args: [fields.len]v8.Value = undefined; - inline for (fields, 0..) |f, i| { - js_args[i] = try executor.zigValueToJs(@field(aargs, f.name)); - } - - const result = self.func.castToFunction().call(executor.context, js_this, &js_args); - if (result == null) { - return error.JSExecCallback; - } - } - - fn getThis(self: *const Callback) v8.Object { - return self._this orelse self.executor.context.getGlobal(); - } - - // debug/helper to print the source of the JS callback - fn printFunc(self: Callback) !void { - const executor = self.executor; - const value = self.func.castToFunction().toValue(); - const src = try valueToString(executor.call_arena, value, executor.isolate, executor.context); - std.debug.print("{s}\n", .{src}); - } - }; - - pub const JsObject = struct { - js_obj: v8.Object, - executor: *Executor, - - // If a Zig struct wants the Object parameter, it'll declare a - // function like: - // fn _length(self: *const NodeList, js_obj: Env.Object) usize - // - // When we're trying to call this function, we can't just do - // if (params[i].type.? == Object) - // Because there is _no_ object, there's only an Env.Object, where - // Env is a generic. - // We could probably figure out a way to do this, but simply checking - // for this declaration is _a lot_ easier. - const _JSOBJECT_ID_KLUDGE = true; - - pub fn setIndex(self: JsObject, index: usize, value: anytype) !void { - const key = switch (index) { - inline 0...1000 => |i| std.fmt.comptimePrint("{d}", .{i}), - else => try std.fmt.allocPrint(self.executor.scope_arena, "{d}", .{index}), - }; - return self.set(key, value); - } - - pub fn set(self: JsObject, key: []const u8, value: anytype) !void { - const executor = self.executor; - - const js_key = v8.String.initUtf8(executor.isolate, key); - const js_value = try executor.zigValueToJs(value); - if (!self.js_obj.setValue(executor.context, js_key, js_value)) { - return error.FailedToSet; - } - } - }; - - // This only exists so that we know whether a function wants the opaque - // JS argument (JsObject), or if it wants the receiver as an opaque - // value. - // JsObject is normally used when a method wants an opaque JS object - // that it'll pass into a callback. - // JsThis is used when the function wants to do advanced manipulation - // of the v8.Object bound to the instance. For example, postAttach is an - // example of using JsThis. - pub const JsThis = struct { - obj: JsObject, - - const _JSTHIS_ID_KLUDGE = true; - - pub fn setIndex(self: JsThis, index: usize, value: anytype) !void { - return self.obj.setIndex(index, value); - } - - pub fn set(self: JsThis, key: []const u8, value: anytype) !void { - return self.obj.set(key, value); - } - }; - - pub const TryCatch = struct { - inner: v8.TryCatch, - executor: *const Executor, - - pub fn init(self: *TryCatch, executor: *const Executor) void { - self.executor = executor; - self.inner.init(executor.isolate); - } - - pub fn hasCaught(self: TryCatch) bool { - return self.inner.hasCaught(); - } - - // the caller needs to deinit the string returned - pub fn exception(self: TryCatch, allocator: Allocator) !?[]const u8 { - const msg = self.inner.getException() orelse return null; - const executor = self.executor; - return try valueToString(allocator, msg, executor.isolate, executor.context); - } - - // the caller needs to deinit the string returned - pub fn stack(self: TryCatch, allocator: Allocator) !?[]const u8 { - const executor = self.executor; - const s = self.inner.getStackTrace(executor.context) orelse return null; - return try valueToString(allocator, s, executor.isolate, executor.context); - } - - // a shorthand method to return either the entire stack message - // or just the exception message - // - in Debug mode return the stack if available - // - otherwhise return the exception if available - // the caller needs to deinit the string returned - pub fn err(self: TryCatch, allocator: Allocator) !?[]const u8 { - if (builtin.mode == .Debug) { - if (try self.stack(allocator)) |msg| { - return msg; - } - } - return try self.exception(allocator); - } - - pub fn deinit(self: *TryCatch) void { - self.inner.deinit(); - } - }; - - pub const Inspector = struct { - isolate: v8.Isolate, - inner: *v8.Inspector, - session: v8.InspectorSession, - - // We expect allocator to be an arena - pub fn init(allocator: Allocator, executor: *const Executor, ctx: anytype) !Inspector { - const ContextT = @TypeOf(ctx); - - const InspectorContainer = switch (@typeInfo(ContextT)) { - .@"struct" => ContextT, - .pointer => |ptr| ptr.child, - .void => NoopInspector, - else => @compileError("invalid context type"), - }; - - // If necessary, turn a void context into something we can safely ptrCast - const safe_context: *anyopaque = if (ContextT == void) @constCast(@ptrCast(&{})) else ctx; - - const isolate = executor.isolate; - const channel = v8.InspectorChannel.init(safe_context, InspectorContainer.onInspectorResponse, InspectorContainer.onInspectorEvent, isolate); - - const client = v8.InspectorClient.init(); - - const inner = try allocator.create(v8.Inspector); - v8.Inspector.init(inner, client, channel, isolate); - return .{ .inner = inner, .isolate = isolate, .session = inner.connect() }; - } - - pub fn deinit(self: *const Inspector) void { - self.session.deinit(); - self.inner.deinit(); - } - - pub fn send(self: *const Inspector, msg: []const u8) void { - self.session.dispatchProtocolMessage(self.isolate, msg); - } - - // From CDP docs - // https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-ExecutionContextDescription - // ---- - // - name: Human readable name describing given context. - // - origin: Execution context origin (ie. URL who initialised the request) - // - auxData: Embedder-specific auxiliary data likely matching - // {isDefault: boolean, type: 'default'|'isolated'|'worker', frameId: string} - // - is_default_context: Whether the execution context is default, should match the auxData - pub fn contextCreated( - self: *const Inspector, - executor: *const Executor, - name: []const u8, - origin: []const u8, - aux_data: ?[]const u8, - is_default_context: bool, - ) void { - self.inner.contextCreated(executor.context, name, origin, aux_data, is_default_context); - } - - // Retrieves the RemoteObject for a given value. - // The value is loaded through the Executor's mapZigInstanceToJs function, - // just like a method return value. Therefore, if we've mapped this - // value before, we'll get the existing JS PersistedObject and if not - // we'll create it and track it for cleanup when the scope ends. - pub fn getRemoteObject( - self: *const Inspector, - executor: *const Executor, - group: []const u8, - value: anytype, - ) !RemoteObject { - const js_value = try zigValueToJs( - executor.templates, - executor.isolate, - executor.context, - value, - ); - - // We do not want to expose this as a parameter for now - const generate_preview = false; - return self.session.wrapObject( - executor.isolate, - executor.context, - js_value, - group, - generate_preview, - ); - } - - // Gets a value by object ID regardless of which context it is in. - pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []const u8) !?*anyopaque { - const unwrapped = try self.session.unwrapObject(allocator, object_id); - // The values context and groupId are not used here - const toa = getTaggedAnyOpaque(unwrapped.value) orelse return null; - if (toa.subtype == null or toa.subtype != .node) return error.ObjectIdIsNotANode; - return toa.ptr; - } - }; - - pub const RemoteObject = v8.RemoteObject; - - pub const Value = struct { - value: v8.Value, - executor: *const Executor, - - // the caller needs to deinit the string returned - pub fn toString(self: Value, allocator: Allocator) ![]const u8 { - const executor = self.executor; - return valueToString(allocator, self.value, executor.isolate, executor.context); - } - }; - // Reverses the mapZigInstanceToJs, making sure that our TaggedAnyOpaque // contains a ptr to the correct type. fn typeTaggedAnyOpaque(comptime named_function: anytype, comptime R: type, js_obj: v8.Object) !R { @@ -1555,6 +1504,34 @@ pub fn Env(comptime S: type, comptime types: anytype) type { type_index = prototype_index; } } + + // An interface for types that want to their jsScopeEnd function to be + // called when the call scope ends + const CallScopeEndCallback = struct { + ptr: *anyopaque, + callScopeEndFn: *const fn (ptr: *anyopaque, scope: *Scope) void, + + fn init(ptr: anytype) CallScopeEndCallback { + const T = @TypeOf(ptr); + const ptr_info = @typeInfo(T); + + const gen = struct { + pub fn callScopeEnd(pointer: *anyopaque, scope: *Scope) void { + const self: T = @ptrCast(@alignCast(pointer)); + return ptr_info.pointer.child.jsCallScopeEnd(self, scope); + } + }; + + return .{ + .ptr = ptr, + .callScopeEndFn = gen.callScopeEnd, + }; + } + + pub fn callScopeEnd(self: CallScopeEndCallback, scope: *Scope) void { + self.callScopeEndFn(self.ptr, scope); + } + }; }; } @@ -1592,9 +1569,9 @@ fn Caller(comptime E: type) type { const TypeLookup = @TypeOf(TYPE_LOOKUP); return struct { + scope: *E.Scope, context: v8.Context, isolate: v8.Isolate, - executor: *E.Executor, call_arena: Allocator, const Self = @This(); @@ -1605,20 +1582,20 @@ fn Caller(comptime E: type) type { fn init(info: anytype) Self { const isolate = info.getIsolate(); const context = isolate.getCurrentContext(); - const executor: *E.Executor = @ptrFromInt(context.getEmbedderData(1).castTo(v8.BigInt).getUint64()); + const scope: *E.Scope = @ptrFromInt(context.getEmbedderData(1).castTo(v8.BigInt).getUint64()); - executor.call_depth += 1; + scope.call_depth += 1; return .{ + .scope = scope, .isolate = isolate, .context = context, - .executor = executor, - .call_arena = executor.call_arena, + .call_arena = scope.call_arena, }; } fn deinit(self: *Self) void { - const executor = self.executor; - const call_depth = executor.call_depth - 1; + const scope = self.scope; + const call_depth = scope.call_depth - 1; // Because of callbacks, calls can be nested. Because of this, we // can't clear the call_arena after _every_ call. Imagine we have @@ -1631,20 +1608,19 @@ fn Caller(comptime E: type) type { // // Therefore, we keep a call_depth, and only reset the call_arena // when a top-level (call_depth == 0) function ends. - if (call_depth == 0) { - const scope = &self.executor.scope.?; - for (scope.scope_end_callbacks.items) |cb| { - cb.scopeEnd(executor); + for (scope.call_scope_end_callbacks.items) |cb| { + cb.callScopeEnd(scope); } - _ = executor._call_arena_instance.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN }); + const arena: *ArenaAllocator = @alignCast(@ptrCast(scope.call_arena.ptr)); + _ = arena.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN }); } // Set this _after_ we've executed the above code, so that if the // above code executes any callbacks, they aren't being executed // at scope 0, which would be wrong. - executor.call_depth = call_depth; + scope.call_depth = call_depth; } fn constructor(self: *Self, comptime named_function: anytype, info: v8.FunctionCallbackInfo) !void { @@ -1659,9 +1635,9 @@ fn Caller(comptime E: type) type { const this = info.getThis(); if (@typeInfo(ReturnType) == .error_union) { const non_error_res = res catch |err| return err; - _ = try E.Executor.mapZigInstanceToJs(self.context, this, non_error_res); + _ = try E.Scope.mapZigInstanceToJs(self.context, this, non_error_res); } else { - _ = try E.Executor.mapZigInstanceToJs(self.context, this, res); + _ = try E.Scope.mapZigInstanceToJs(self.context, this, res); } info.getReturnValue().set(this); } @@ -1697,7 +1673,7 @@ fn Caller(comptime E: type) type { @field(args, "0") = zig_instance; if (comptime arg_fields.len == 2) { comptime assertIsStateArg(named_function, 1); - @field(args, "1") = self.executor.state; + @field(args, "1") = self.scope.state; } }, else => @compileError(named_function.full_name + " has too many parmaters: " ++ @typeName(named_function.func)), @@ -1723,7 +1699,7 @@ fn Caller(comptime E: type) type { @field(args, "1") = try self.jsValueToZig(named_function, arg_fields[1].type, js_value); if (comptime arg_fields.len == 3) { comptime assertIsStateArg(named_function, 2); - @field(args, "2") = self.executor.state; + @field(args, "2") = self.scope.state; } }, else => @compileError(named_function.full_name ++ " setter with more than 3 parameters, why?"), @@ -1759,7 +1735,7 @@ fn Caller(comptime E: type) type { @field(args, "2") = &has_value; if (comptime arg_fields.len == 4) { comptime assertIsStateArg(named_function, 3); - @field(args, "3") = self.executor.state; + @field(args, "3") = self.scope.state; } }, else => @compileError(named_function.full_name ++ " has too many parmaters"), @@ -1795,7 +1771,7 @@ fn Caller(comptime E: type) type { @field(args, "2") = &has_value; if (comptime arg_fields.len == 4) { comptime assertIsStateArg(named_function, 3); - @field(args, "3") = self.executor.state; + @field(args, "3") = self.scope.state; } }, else => @compileError(named_function.full_name ++ " has too many parmaters"), @@ -1944,7 +1920,7 @@ fn Caller(comptime E: type) type { // from our params slice, because we don't want to bind it to // a JS argument if (comptime isState(params[params.len - 1].type.?)) { - @field(args, std.fmt.comptimePrint("{d}", .{params.len - 1 + offset})) = self.executor.state; + @field(args, std.fmt.comptimePrint("{d}", .{params.len - 1 + offset})) = self.scope.state; break :blk params[0 .. params.len - 1]; } @@ -1959,7 +1935,7 @@ fn Caller(comptime E: type) type { // AND the 2nd last parameter is state if (params.len > 1 and comptime isState(params[params.len - 2].type.?)) { - @field(args, std.fmt.comptimePrint("{d}", .{params.len - 2 + offset})) = self.executor.state; + @field(args, std.fmt.comptimePrint("{d}", .{params.len - 2 + offset})) = self.scope.state; break :blk params[0 .. params.len - 2]; } @@ -2160,13 +2136,13 @@ fn Caller(comptime E: type) type { return error.InvalidArgument; } - const executor = self.executor; const func = v8.Persistent(v8.Function).init(self.isolate, js_value.castTo(v8.Function)); - try executor.scope.?.trackCallback(func); + const scope = self.scope; + try scope.trackCallback(func); return .{ .func = func, - .executor = executor, + .scope = scope, .id = js_value.castTo(v8.Object).getIdentityHash(), }; } @@ -2182,7 +2158,7 @@ fn Caller(comptime E: type) type { // that it needs to pass back into a callback return E.JsObject{ .js_obj = js_obj, - .executor = self.executor, + .scope = self.scope, }; } @@ -2261,7 +2237,7 @@ fn Caller(comptime E: type) type { } fn zigValueToJs(self: *const Self, value: anytype) !v8.Value { - return self.executor.zigValueToJs(value); + return self.scope.zigValueToJs(value); } fn isState(comptime T: type) bool { @@ -2529,6 +2505,12 @@ const NoopInspector = struct { pub fn onInspectorEvent(_: *anyopaque, _: []const u8) void {} }; +const ErrorModuleLoader = struct { + pub fn fetchModuleSource(_: *anyopaque, _: []const u8) ![]const u8 { + return error.NoModuleLoadConfigured; + } +}; + // If we have a struct: // const Cat = struct { // pub fn meow(self: *Cat) void { ... } diff --git a/src/runtime/test_primitive_types.zig b/src/runtime/test_primitive_types.zig index 9ba04261..eccf5202 100644 --- a/src/runtime/test_primitive_types.zig +++ b/src/runtime/test_primitive_types.zig @@ -84,7 +84,6 @@ const Primitives = struct { return v; } pub fn _checkNonOptional(_: *const Primitives, v: u8) u8 { - std.debug.print("x: {d}\n", .{v}); return v; } pub fn _checkOptionalReturn(_: *const Primitives) ?bool { diff --git a/src/runtime/testing.zig b/src/runtime/testing.zig index fcfab82b..951b9a67 100644 --- a/src/runtime/testing.zig +++ b/src/runtime/testing.zig @@ -30,29 +30,31 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty return struct { env: *Env, - executor: *Env.Executor, + scope: *Env.Scope, + executor: Env.Executor, const Self = @This(); pub fn init(state: State, global: Global) !*Self { - const runner = try allocator.create(Self); - errdefer allocator.destroy(runner); + const self = try allocator.create(Self); + errdefer allocator.destroy(self); - runner.env = try Env.init(allocator, .{}); - errdefer runner.env.deinit(); + self.env = try Env.init(allocator, .{}); + errdefer self.env.deinit(); - const G = if (Global == void) DefaultGlobal else Global; + self.executor = try self.env.newExecutor(); + errdefer self.executor.deinit(); - runner.executor = try runner.env.startExecutor(G, state, runner, .main); - errdefer runner.env.stopExecutor(runner.executor); - - try runner.executor.startScope(if (Global == void) &default_global else global); - return runner; + self.scope = try self.executor.startScope( + if (Global == void) &default_global else global, + state, + {}, + ); + return self; } pub fn deinit(self: *Self) void { - self.executor.endScope(); - self.env.stopExecutor(self.executor); + self.executor.deinit(); self.env.deinit(); allocator.destroy(self); } @@ -62,10 +64,10 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty pub fn testCases(self: *Self, cases: []const Case, _: RunOpts) !void { for (cases, 0..) |case, i| { var try_catch: Env.TryCatch = undefined; - try_catch.init(self.executor); + try_catch.init(self.scope); defer try_catch.deinit(); - const value = self.executor.exec(case.@"0", null) catch |err| { + const value = self.scope.exec(case.@"0", null) catch |err| { if (try try_catch.err(allocator)) |msg| { defer allocator.free(msg); if (isExpectedTypeError(case.@"1", msg)) { @@ -84,12 +86,6 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty } } } - - pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) ![]const u8 { - _ = ctx; - _ = specifier; - return error.DummyModuleLoader; - } }; } diff --git a/src/testing.zig b/src/testing.zig index 191dde5b..7fa96e03 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -381,7 +381,8 @@ pub const JsRunner = struct { arena: Allocator, renderer: Renderer, http_client: HttpClient, - executor: *Env.Executor, + scope: *Env.Scope, + executor: Env.Executor, storage_shelf: storage.Shelf, cookie_jar: storage.CookieJar, @@ -394,55 +395,55 @@ pub const JsRunner = struct { errdefer aa.deinit(); const arena = aa.allocator(); - const runner = try arena.create(JsRunner); - runner.arena = arena; + const self = try arena.create(JsRunner); + self.arena = arena; - runner.env = try Env.init(arena, .{}); - errdefer runner.env.deinit(); + self.env = try Env.init(arena, .{}); + errdefer self.env.deinit(); - runner.url = try URL.parse("https://lightpanda.io/opensource-browser/", null); + self.url = try URL.parse("https://lightpanda.io/opensource-browser/", null); - runner.renderer = Renderer.init(arena); - runner.cookie_jar = storage.CookieJar.init(arena); - runner.loop = try Loop.init(arena); - errdefer runner.loop.deinit(); + self.renderer = Renderer.init(arena); + self.cookie_jar = storage.CookieJar.init(arena); + self.loop = try Loop.init(arena); + errdefer self.loop.deinit(); var html = std.io.fixedBufferStream(opts.html); const document = try parser.documentHTMLParse(html.reader(), "UTF-8"); - runner.state = .{ + self.state = .{ .arena = arena, - .loop = &runner.loop, + .loop = &self.loop, .document = document, - .url = &runner.url, - .renderer = &runner.renderer, - .cookie_jar = &runner.cookie_jar, - .http_client = &runner.http_client, + .url = &self.url, + .renderer = &self.renderer, + .cookie_jar = &self.cookie_jar, + .http_client = &self.http_client, }; - runner.window = .{}; - try runner.window.replaceDocument(document); - try runner.window.replaceLocation(.{ - .url = try runner.url.toWebApi(arena), + self.window = .{}; + try self.window.replaceDocument(document); + try self.window.replaceLocation(.{ + .url = try self.url.toWebApi(arena), }); - runner.storage_shelf = storage.Shelf.init(arena); - runner.window.setStorageShelf(&runner.storage_shelf); + self.storage_shelf = storage.Shelf.init(arena); + self.window.setStorageShelf(&self.storage_shelf); - runner.http_client = try HttpClient.init(arena, 1, .{ + self.http_client = try HttpClient.init(arena, 1, .{ .tls_verify_host = false, }); - runner.executor = try runner.env.startExecutor(Window, &runner.state, runner, .main); - errdefer runner.env.stopExecutor(runner.executor); + self.executor = try self.env.newExecutor(); + errdefer self.executor.deinit(); - try runner.executor.startScope(&runner.window); - return runner; + self.scope = try self.executor.startScope(&self.window, &self.state, {}); + return self; } pub fn deinit(self: *JsRunner) void { self.loop.deinit(); - self.executor.endScope(); + self.executor.deinit(); self.env.deinit(); self.http_client.deinit(); self.storage_shelf.deinit(); @@ -459,10 +460,10 @@ pub const JsRunner = struct { for (cases, 0..) |case, i| { var try_catch: Env.TryCatch = undefined; - try_catch.init(self.executor); + try_catch.init(self.scope); defer try_catch.deinit(); - const value = self.executor.exec(case.@"0", null) catch |err| { + const value = self.scope.exec(case.@"0", null) catch |err| { if (try try_catch.err(self.arena)) |msg| { std.debug.print("{s}\n\nCase: {d}\n{s}\n", .{ msg, i + 1, case.@"0" }); } @@ -485,10 +486,10 @@ pub const JsRunner = struct { pub fn eval(self: *JsRunner, src: []const u8, name: ?[]const u8, err_msg: *?[]const u8) !Env.Value { var try_catch: Env.TryCatch = undefined; - try_catch.init(self.executor); + try_catch.init(self.scope); defer try_catch.deinit(); - return self.executor.exec(src, name) catch |err| { + return self.scope.exec(src, name) catch |err| { if (try try_catch.err(self.arena)) |msg| { err_msg.* = msg; std.debug.print("Error running script: {s}\n", .{msg}); @@ -496,12 +497,6 @@ pub const JsRunner = struct { return err; }; } - - pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) ![]const u8 { - _ = ctx; - _ = specifier; - return error.DummyModuleLoader; - } }; const RunnerOpts = struct { diff --git a/src/url.zig b/src/url.zig index 53c59085..709c209b 100644 --- a/src/url.zig +++ b/src/url.zig @@ -8,6 +8,8 @@ pub const URL = struct { uri: Uri, raw: []const u8, + pub const empty = URL{ .uri = .{ .scheme = "" }, .raw = "" }; + // We assume str will last as long as the URL // In some cases, this is safe to do, because we know the URL is short lived. // In most cases though, we assume the caller will just dupe the string URL diff --git a/src/wpt/run.zig b/src/wpt/run.zig index fcca32d0..a0378973 100644 --- a/src/wpt/run.zig +++ b/src/wpt/run.zig @@ -50,7 +50,7 @@ pub fn run(arena: Allocator, comptime dir: []const u8, f: []const u8, loader: *F .html = html, }); defer runner.deinit(); - try polyfill.load(arena, runner.executor); + try polyfill.load(arena, runner.scope); // display console logs defer { @@ -106,7 +106,7 @@ pub fn run(arena: Allocator, comptime dir: []const u8, f: []const u8, loader: *F // wait for all async executions { var try_catch: Env.TryCatch = undefined; - try_catch.init(runner.executor); + try_catch.init(runner.scope); defer try_catch.deinit(); runner.loop.run() catch |err| { if (try try_catch.err(arena)) |msg| {