diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index f402ead7..86e16cc4 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -17,7 +17,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.1.6' + default: 'v0.1.8' v8: description: 'v8 version to install' required: false @@ -47,7 +47,7 @@ runs: cache-name: cache-v8 with: path: ${{ inputs.cache-dir }}/v8 - key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}.a + key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}_${{ inputs.zig-v8 }}.a - if: ${{ steps.cache-v8.outputs.cache-hit != 'true' }} shell: bash diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 757d1a1c..b4e8d3d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ jobs: ARCH: x86_64 OS: linux - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index 10fdfeaf..67f1bc5d 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -44,7 +44,7 @@ jobs: # Don't run the CI with draft PR. if: github.event.pull_request.draft == false - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -86,7 +86,7 @@ jobs: # Don't execute on PR if: github.event_name != 'pull_request' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 container: image: ghcr.io/lightpanda-io/perf-fmt:latest credentials: diff --git a/.github/workflows/zig-fmt.yml b/.github/workflows/zig-fmt.yml index a425f988..60b90e04 100644 --- a/.github/workflows/zig-fmt.yml +++ b/.github/workflows/zig-fmt.yml @@ -28,7 +28,7 @@ jobs: # Don't run the CI with draft PR. if: github.event.pull_request.draft == false - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: mlugg/setup-zig@v1 diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml index a341c9c0..ee6c3d1a 100644 --- a/.github/workflows/zig-test.yml +++ b/.github/workflows/zig-test.yml @@ -42,7 +42,7 @@ jobs: # Don't run the CI with draft PR. if: github.event.pull_request.draft == false - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -63,7 +63,7 @@ jobs: # Don't run the CI on PR if: github.event_name != 'pull_request' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -84,7 +84,7 @@ jobs: # Don't run the CI with draft PR. if: github.event.pull_request.draft == false - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -119,7 +119,7 @@ jobs: # Don't execute on PR if: github.event_name != 'pull_request' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 container: image: ghcr.io/lightpanda-io/perf-fmt:latest credentials: diff --git a/src/browser/browser.zig b/src/browser/browser.zig index 100b4498..9308ed82 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -49,24 +49,29 @@ const log = std.log.scoped(.browser); // A browser contains only one session. // TODO allow multiple sessions per browser. pub const Browser = struct { - session: *Session, + session: Session = undefined, - pub fn init(alloc: std.mem.Allocator, vm: jsruntime.VM) !Browser { + const uri = "about:blank"; + + pub fn init(self: *Browser, alloc: std.mem.Allocator, loop: *Loop, vm: jsruntime.VM) !void { // We want to ensure the caller initialised a VM, but the browser // doesn't use it directly... _ = vm; - return Browser{ - .session = try Session.init(alloc, "about:blank"), - }; + try Session.init(&self.session, alloc, loop, uri); } pub fn deinit(self: *Browser) void { self.session.deinit(); } - pub fn currentSession(self: *Browser) *Session { - return self.session; + pub fn newSession( + self: *Browser, + alloc: std.mem.Allocator, + loop: *jsruntime.Loop, + ) !void { + self.session.deinit(); + try Session.init(&self.session, alloc, loop, uri); } }; @@ -90,37 +95,37 @@ pub const Session = struct { // TODO handle proxy loader: Loader, env: Env = undefined, - loop: Loop, + inspector: ?jsruntime.Inspector = null, window: Window, // TODO move the shed to the browser? storageShed: storage.Shed, - page: ?*Page = null, + page: ?Page = null, httpClient: HttpClient, jstypes: [Types.len]usize = undefined, - fn init(alloc: std.mem.Allocator, uri: []const u8) !*Session { - var self = try alloc.create(Session); + fn init(self: *Session, alloc: std.mem.Allocator, loop: *Loop, uri: []const u8) !void { self.* = Session{ .uri = uri, .alloc = alloc, .arena = std.heap.ArenaAllocator.init(alloc), .window = Window.create(null), .loader = Loader.init(alloc), - .loop = try Loop.init(alloc), .storageShed = storage.Shed.init(alloc), .httpClient = undefined, }; - self.env = try Env.init(self.arena.allocator(), &self.loop, null); - self.httpClient = .{ .allocator = alloc, .loop = &self.loop }; + Env.init(&self.env, self.arena.allocator(), loop, null); + self.httpClient = .{ .allocator = alloc, .loop = loop }; try self.env.load(&self.jstypes); - - return self; } fn deinit(self: *Session) void { - if (self.page) |page| page.end(); + if (self.page) |*p| p.end(); + + if (self.inspector) |inspector| { + inspector.deinit(self.alloc); + } self.env.deinit(); self.arena.deinit(); @@ -128,12 +133,35 @@ pub const Session = struct { self.httpClient.deinit(); self.loader.deinit(); self.storageShed.deinit(); - self.loop.deinit(); - self.alloc.destroy(self); } - pub fn createPage(self: *Session) !Page { - return Page.init(self.alloc, self); + pub fn initInspector( + self: *Session, + ctx: anytype, + onResp: jsruntime.InspectorOnResponseFn, + onEvent: jsruntime.InspectorOnEventFn, + ) !void { + const ctx_opaque = @as(*anyopaque, @ptrCast(ctx)); + self.inspector = try jsruntime.Inspector.init(self.alloc, self.env, ctx_opaque, onResp, onEvent); + self.env.setInspector(self.inspector.?); + } + + pub fn callInspector(self: *Session, msg: []const u8) void { + if (self.inspector) |inspector| { + inspector.send(msg, self.env); + } else { + @panic("No Inspector"); + } + } + + // 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) !*Page { + if (self.page != null) return error.SessionPageExists; + const p: Page = undefined; + self.page = p; + Page.init(&self.page.?, self.alloc, self); + return &self.page.?; } }; @@ -155,16 +183,14 @@ pub const Page = struct { raw_data: ?[]const u8 = null, fn init( + self: *Page, alloc: std.mem.Allocator, session: *Session, - ) !Page { - if (session.page != null) return error.SessionPageExists; - var page = Page{ + ) void { + self.* = .{ .arena = std.heap.ArenaAllocator.init(alloc), .session = session, }; - session.page = &page; - return page; } // reset js env and mem arena. @@ -219,7 +245,9 @@ pub const Page = struct { } // spec reference: https://html.spec.whatwg.org/#document-lifecycle - pub fn navigate(self: *Page, uri: []const u8) !void { + // - auxData: extra data forwarded to the Inspector + // see Inspector.contextCreated + pub fn navigate(self: *Page, uri: []const u8, auxData: ?[]const u8) !void { const alloc = self.arena.allocator(); log.debug("starting GET {s}", .{uri}); @@ -280,7 +308,7 @@ pub const Page = struct { log.debug("header content-type: {s}", .{ct.?}); const mime = try Mime.parse(ct.?); if (mime.eql(Mime.HTML)) { - try self.loadHTMLDoc(req.reader(), mime.charset orelse "utf-8"); + try self.loadHTMLDoc(req.reader(), mime.charset orelse "utf-8", auxData); } else { log.info("non-HTML document: {s}", .{ct.?}); @@ -290,7 +318,7 @@ pub const Page = struct { } // https://html.spec.whatwg.org/#read-html - fn loadHTMLDoc(self: *Page, reader: anytype, charset: []const u8) !void { + fn loadHTMLDoc(self: *Page, reader: anytype, charset: []const u8, auxData: ?[]const u8) !void { const alloc = self.arena.allocator(); // start netsurf memory arena. @@ -327,6 +355,11 @@ pub const Page = struct { log.debug("start js env", .{}); try self.session.env.start(); + // inspector + if (self.session.inspector) |inspector| { + inspector.contextCreated(self.session.env, "", self.origin.?, auxData); + } + // replace the user context document with the new one. try self.session.env.setUserContext(.{ .document = html_doc, diff --git a/src/cdp/browser.zig b/src/cdp/browser.zig new file mode 100644 index 00000000..4bfe17a2 --- /dev/null +++ b/src/cdp/browser.zig @@ -0,0 +1,150 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; +const cdp = @import("cdp.zig"); +const result = cdp.result; +const getMsg = cdp.getMsg; + +const log = std.log.scoped(.cdp); + +const Methods = enum { + getVersion, + setDownloadBehavior, + getWindowForTarget, + setWindowBounds, +}; + +pub fn browser( + alloc: std.mem.Allocator, + id: ?u16, + action: []const u8, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + const method = std.meta.stringToEnum(Methods, action) orelse + return error.UnknownMethod; + return switch (method) { + .getVersion => getVersion(alloc, id, scanner, ctx), + .setDownloadBehavior => setDownloadBehavior(alloc, id, scanner, ctx), + .getWindowForTarget => getWindowForTarget(alloc, id, scanner, ctx), + .setWindowBounds => setWindowBounds(alloc, id, scanner, ctx), + }; +} + +// TODO: hard coded data +const ProtocolVersion = "1.3"; +const Product = "Chrome/124.0.6367.29"; +const Revision = "@9e6ded5ac1ff5e38d930ae52bd9aec09bd1a68e4"; +const UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +const JsVersion = "12.4.254.8"; + +fn getVersion( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const msg = try getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "browser.getVersion" }); + + // ouput + const Res = struct { + protocolVersion: []const u8 = ProtocolVersion, + product: []const u8 = Product, + revision: []const u8 = Revision, + userAgent: []const u8 = UserAgent, + jsVersion: []const u8 = JsVersion, + }; + return result(alloc, msg.id, Res, .{}, null); +} + +// TODO: noop method +fn setDownloadBehavior( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + behavior: []const u8, + browserContextId: ?[]const u8 = null, + downloadPath: ?[]const u8 = null, + eventsEnabled: ?bool = null, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("REQ > id {d}, method {s}", .{ msg.id, "browser.setDownloadBehavior" }); + + // output + return result(alloc, msg.id, null, null, null); +} + +// TODO: hard coded ID +const DevToolsWindowID = 1923710101; + +fn getWindowForTarget( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + targetId: ?[]const u8 = null, + }; + const msg = try cdp.getMsg(alloc, _id, ?Params, scanner); + std.debug.assert(msg.sessionID != null); + log.debug("Req > id {d}, method {s}", .{ msg.id, "browser.getWindowForTarget" }); + + // output + const Resp = struct { + windowId: u64 = DevToolsWindowID, + bounds: struct { + left: ?u64 = null, + top: ?u64 = null, + width: ?u64 = null, + height: ?u64 = null, + windowState: []const u8 = "normal", + } = .{}, + }; + return result(alloc, msg.id, Resp, Resp{}, msg.sessionID.?); +} + +// TODO: noop method +fn setWindowBounds( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const msg = try cdp.getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "browser.setWindowBounds" }); + + // output + return result(alloc, msg.id, null, null, msg.sessionID); +} diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig new file mode 100644 index 00000000..692b1109 --- /dev/null +++ b/src/cdp/cdp.zig @@ -0,0 +1,335 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; + +const browser = @import("browser.zig").browser; +const target = @import("target.zig").target; +const page = @import("page.zig").page; +const log = @import("log.zig").log; +const runtime = @import("runtime.zig").runtime; +const network = @import("network.zig").network; +const emulation = @import("emulation.zig").emulation; +const fetch = @import("fetch.zig").fetch; +const performance = @import("performance.zig").performance; + +const log_cdp = std.log.scoped(.cdp); + +pub const Error = error{ + UnknonwDomain, + UnknownMethod, + NoResponse, + RequestWithoutID, +}; + +pub fn isCdpError(err: anyerror) ?Error { + // see https://github.com/ziglang/zig/issues/2473 + const errors = @typeInfo(Error).ErrorSet.?; + inline for (errors) |e| { + if (std.mem.eql(u8, e.name, @errorName(err))) { + return @errorCast(err); + } + } + return null; +} + +const Domains = enum { + Browser, + Target, + Page, + Log, + Runtime, + Network, + Emulation, + Fetch, + Performance, +}; + +// The caller is responsible for calling `free` on the returned slice. +pub fn do( + alloc: std.mem.Allocator, + s: []const u8, + ctx: *Ctx, +) ![]const u8 { + + // JSON scanner + var scanner = std.json.Scanner.initCompleteInput(alloc, s); + defer scanner.deinit(); + + std.debug.assert(try scanner.next() == .object_begin); + + // handle 2 possible orders: + // - id, method <...> + // - method, id <...> + var method_key = try nextString(&scanner); + var method_token: std.json.Token = undefined; + var id: ?u16 = null; + // check swap order + if (std.mem.eql(u8, method_key, "id")) { + id = try getId(&scanner, method_key); + method_key = try nextString(&scanner); + method_token = try scanner.next(); + } else { + method_token = try scanner.next(); + } + try checkKey(method_key, "method"); + + // retrieve method + if (method_token != .string) { + return error.WrongTokenType; + } + const method_name = method_token.string; + + // retrieve domain from method + var iter = std.mem.splitScalar(u8, method_name, '.'); + const domain = std.meta.stringToEnum(Domains, iter.first()) orelse + return error.UnknonwDomain; + + // select corresponding domain + const action = iter.next() orelse return error.BadMethod; + return switch (domain) { + .Browser => browser(alloc, id, action, &scanner, ctx), + .Target => target(alloc, id, action, &scanner, ctx), + .Page => page(alloc, id, action, &scanner, ctx), + .Log => log(alloc, id, action, &scanner, ctx), + .Runtime => runtime(alloc, id, action, &scanner, s, ctx), + .Network => network(alloc, id, action, &scanner, ctx), + .Emulation => emulation(alloc, id, action, &scanner, ctx), + .Fetch => fetch(alloc, id, action, &scanner, ctx), + .Performance => performance(alloc, id, action, &scanner, ctx), + }; +} + +pub const State = struct { + executionContextId: u8 = 0, + contextID: ?[]const u8 = null, + frameID: []const u8 = FrameID, + url: []const u8 = URLBase, + securityOrigin: []const u8 = URLBase, + secureContextType: []const u8 = "Secure", // TODO: enum + loaderID: []const u8 = LoaderID, + + page_life_cycle_events: bool = false, // TODO; Target based value +}; + +// Utils +// ----- + +fn nextString(scanner: *std.json.Scanner) ![]const u8 { + const token = try scanner.next(); + if (token != .string) { + return error.WrongTokenType; + } + return token.string; +} + +pub fn dumpFile( + alloc: std.mem.Allocator, + id: u16, + script: []const u8, +) !void { + const name = try std.fmt.allocPrint(alloc, "id_{d}.js", .{id}); + defer alloc.free(name); + const dir = try std.fs.cwd().makeOpenPath("zig-cache/tmp", .{}); + const f = try dir.createFile(name, .{}); + defer f.close(); + const nb = try f.write(script); + std.debug.assert(nb == script.len); + const p = try dir.realpathAlloc(alloc, name); + defer alloc.free(p); +} + +fn checkKey(key: []const u8, token: []const u8) !void { + if (!std.mem.eql(u8, key, token)) return error.WrongToken; +} + +// caller owns the slice returned +pub fn stringify(alloc: std.mem.Allocator, res: anytype) ![]const u8 { + var out = std.ArrayList(u8).init(alloc); + defer out.deinit(); + + // Do not emit optional null fields + const options: std.json.StringifyOptions = .{ .emit_null_optional_fields = false }; + + try std.json.stringify(res, options, out.writer()); + const ret = try alloc.alloc(u8, out.items.len); + @memcpy(ret, out.items); + return ret; +} + +const resultNull = "{{\"id\": {d}, \"result\": {{}}}}"; +const resultNullSession = "{{\"id\": {d}, \"result\": {{}}, \"sessionId\": \"{s}\"}}"; + +// caller owns the slice returned +pub fn result( + alloc: std.mem.Allocator, + id: u16, + comptime T: ?type, + res: anytype, + sessionID: ?[]const u8, +) ![]const u8 { + log_cdp.debug( + "Res > id {d}, sessionID {?s}, result {any}", + .{ id, sessionID, res }, + ); + if (T == null) { + // No need to stringify a custom JSON msg, just use string templates + if (sessionID) |sID| { + return try std.fmt.allocPrint(alloc, resultNullSession, .{ id, sID }); + } + return try std.fmt.allocPrint(alloc, resultNull, .{id}); + } + + const Resp = struct { + id: u16, + result: T.?, + sessionId: ?[]const u8, + }; + const resp = Resp{ .id = id, .result = res, .sessionId = sessionID }; + + return stringify(alloc, resp); +} + +pub fn sendEvent( + alloc: std.mem.Allocator, + ctx: *Ctx, + name: []const u8, + comptime T: type, + params: T, + sessionID: ?[]const u8, +) !void { + log_cdp.debug("Event > method {s}, sessionID {?s}", .{ name, sessionID }); + const Resp = struct { + method: []const u8, + params: T, + sessionId: ?[]const u8, + }; + const resp = Resp{ .method = name, .params = params, .sessionId = sessionID }; + + const event_msg = try stringify(alloc, resp); + defer alloc.free(event_msg); + try server.sendSync(ctx, event_msg); +} + +fn getParams( + alloc: std.mem.Allocator, + comptime T: type, + scanner: *std.json.Scanner, + key: []const u8, +) !?T { + + // check key is "params" + if (!std.mem.eql(u8, "params", key)) return null; + + // skip "params" if not requested + if (T == void) { + var finished: usize = 0; + while (true) { + switch (try scanner.next()) { + .object_begin => finished += 1, + .object_end => finished -= 1, + else => continue, + } + if (finished == 0) break; + } + return void{}; + } + + // parse "params" + const options = std.json.ParseOptions{ + .max_value_len = scanner.input.len, + .allocate = .alloc_if_needed, + }; + return try std.json.innerParse(T, alloc, scanner, options); +} + +fn getId(scanner: *std.json.Scanner, key: []const u8) !?u16 { + + // check key is "id" + if (!std.mem.eql(u8, "id", key)) return null; + + // parse "id" + return try std.fmt.parseUnsigned(u16, (try scanner.next()).number, 10); +} + +fn getSessionId(scanner: *std.json.Scanner, key: []const u8) !?[]const u8 { + + // check key is "sessionId" + if (!std.mem.eql(u8, "sessionId", key)) return null; + + // parse "sessionId" + return try nextString(scanner); +} + +pub fn getMsg( + alloc: std.mem.Allocator, + _id: ?u16, + comptime params_T: type, + scanner: *std.json.Scanner, +) !struct { id: u16, params: ?params_T, sessionID: ?[]const u8 } { + var id_msg: ?u16 = null; + var params: ?params_T = null; + var sessionID: ?[]const u8 = null; + + var t: std.json.Token = undefined; + + while (true) { + t = try scanner.next(); + if (t == .object_end) break; + if (t != .string) { + return error.WrongTokenType; + } + if (_id == null and id_msg == null) { + id_msg = try getId(scanner, t.string); + if (id_msg != null) continue; + } + if (params == null) { + params = try getParams(alloc, params_T, scanner, t.string); + if (params != null) continue; + } + if (sessionID == null) { + sessionID = try getSessionId(scanner, t.string); + } + } + + // end + t = try scanner.next(); + if (t != .end_of_document) return error.CDPMsgEnd; + + // check id + if (_id == null and id_msg == null) return error.RequestWithoutID; + + return .{ .id = _id orelse id_msg.?, .params = params, .sessionID = sessionID }; +} + +// Common +// ------ + +// TODO: hard coded IDs +pub const BrowserSessionID = "9559320D92474062597D9875C664CAC0"; +pub const ContextSessionID = "4FDC2CB760A23A220497A05C95417CF4"; +pub const URLBase = "chrome://newtab/"; +pub const FrameID = "90D14BBD8AED408A0467AC93100BCDBE"; +pub const LoaderID = "CFC8BED824DD2FD56CF1EF33C965C79C"; + +pub const TimestampEvent = struct { + timestamp: f64, +}; diff --git a/src/cdp/emulation.zig b/src/cdp/emulation.zig new file mode 100644 index 00000000..4bb32bc3 --- /dev/null +++ b/src/cdp/emulation.zig @@ -0,0 +1,125 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; +const cdp = @import("cdp.zig"); +const result = cdp.result; +const getMsg = cdp.getMsg; +const stringify = cdp.stringify; + +const log = std.log.scoped(.cdp); + +const Methods = enum { + setEmulatedMedia, + setFocusEmulationEnabled, + setDeviceMetricsOverride, + setTouchEmulationEnabled, +}; + +pub fn emulation( + alloc: std.mem.Allocator, + id: ?u16, + action: []const u8, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + const method = std.meta.stringToEnum(Methods, action) orelse + return error.UnknownMethod; + return switch (method) { + .setEmulatedMedia => setEmulatedMedia(alloc, id, scanner, ctx), + .setFocusEmulationEnabled => setFocusEmulationEnabled(alloc, id, scanner, ctx), + .setDeviceMetricsOverride => setDeviceMetricsOverride(alloc, id, scanner, ctx), + .setTouchEmulationEnabled => setTouchEmulationEnabled(alloc, id, scanner, ctx), + }; +} + +const MediaFeature = struct { + name: []const u8, + value: []const u8, +}; + +// TODO: noop method +fn setEmulatedMedia( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + media: ?[]const u8 = null, + features: ?[]MediaFeature = null, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "emulation.setEmulatedMedia" }); + + // output + return result(alloc, msg.id, null, null, msg.sessionID); +} + +// TODO: noop method +fn setFocusEmulationEnabled( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + enabled: bool, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "emulation.setFocusEmulationEnabled" }); + + // output + return result(alloc, msg.id, null, null, msg.sessionID); +} + +// TODO: noop method +fn setDeviceMetricsOverride( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const msg = try cdp.getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "emulation.setDeviceMetricsOverride" }); + + // output + return result(alloc, msg.id, null, null, msg.sessionID); +} + +// TODO: noop method +fn setTouchEmulationEnabled( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + const msg = try cdp.getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "emulation.setTouchEmulationEnabled" }); + + return result(alloc, msg.id, null, null, msg.sessionID); +} diff --git a/src/cdp/fetch.zig b/src/cdp/fetch.zig new file mode 100644 index 00000000..2bdeecab --- /dev/null +++ b/src/cdp/fetch.zig @@ -0,0 +1,59 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; +const cdp = @import("cdp.zig"); +const result = cdp.result; +const getMsg = cdp.getMsg; + +const log = std.log.scoped(.cdp); + +const Methods = enum { + disable, +}; + +pub fn fetch( + alloc: std.mem.Allocator, + id: ?u16, + action: []const u8, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + const method = std.meta.stringToEnum(Methods, action) orelse + return error.UnknownMethod; + + return switch (method) { + .disable => disable(alloc, id, scanner, ctx), + }; +} + +// TODO: noop method +fn disable( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + const msg = try getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "fetch.disable" }); + + return result(alloc, msg.id, null, null, msg.sessionID); +} diff --git a/src/cdp/log.zig b/src/cdp/log.zig new file mode 100644 index 00000000..d2cf5ccc --- /dev/null +++ b/src/cdp/log.zig @@ -0,0 +1,59 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; +const cdp = @import("cdp.zig"); +const result = cdp.result; +const getMsg = cdp.getMsg; +const stringify = cdp.stringify; + +const log_cdp = std.log.scoped(.cdp); + +const Methods = enum { + enable, +}; + +pub fn log( + alloc: std.mem.Allocator, + id: ?u16, + action: []const u8, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + const method = std.meta.stringToEnum(Methods, action) orelse + return error.UnknownMethod; + + return switch (method) { + .enable => enable(alloc, id, scanner, ctx), + }; +} + +fn enable( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + const msg = try getMsg(alloc, _id, void, scanner); + log_cdp.debug("Req > id {d}, method {s}", .{ msg.id, "log.enable" }); + + return result(alloc, msg.id, null, null, msg.sessionID); +} diff --git a/src/cdp/network.zig b/src/cdp/network.zig new file mode 100644 index 00000000..15c93df3 --- /dev/null +++ b/src/cdp/network.zig @@ -0,0 +1,77 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; +const cdp = @import("cdp.zig"); +const result = cdp.result; +const getMsg = cdp.getMsg; + +const log = std.log.scoped(.cdp); + +const Methods = enum { + enable, + setCacheDisabled, +}; + +pub fn network( + alloc: std.mem.Allocator, + id: ?u16, + action: []const u8, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + const method = std.meta.stringToEnum(Methods, action) orelse + return error.UnknownMethod; + + return switch (method) { + .enable => enable(alloc, id, scanner, ctx), + .setCacheDisabled => setCacheDisabled(alloc, id, scanner, ctx), + }; +} + +fn enable( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const msg = try getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "network.enable" }); + + return result(alloc, msg.id, null, null, msg.sessionID); +} + +// TODO: noop method +fn setCacheDisabled( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const msg = try getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "network.setCacheDisabled" }); + + return result(alloc, msg.id, null, null, msg.sessionID); +} diff --git a/src/cdp/page.zig b/src/cdp/page.zig new file mode 100644 index 00000000..1c5076c5 --- /dev/null +++ b/src/cdp/page.zig @@ -0,0 +1,461 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; +const cdp = @import("cdp.zig"); +const result = cdp.result; +const getMsg = cdp.getMsg; +const stringify = cdp.stringify; +const sendEvent = cdp.sendEvent; + +const log = std.log.scoped(.cdp); + +const Runtime = @import("runtime.zig"); + +const Methods = enum { + enable, + getFrameTree, + setLifecycleEventsEnabled, + addScriptToEvaluateOnNewDocument, + createIsolatedWorld, + navigate, +}; + +pub fn page( + alloc: std.mem.Allocator, + id: ?u16, + action: []const u8, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + const method = std.meta.stringToEnum(Methods, action) orelse + return error.UnknownMethod; + return switch (method) { + .enable => enable(alloc, id, scanner, ctx), + .getFrameTree => getFrameTree(alloc, id, scanner, ctx), + .setLifecycleEventsEnabled => setLifecycleEventsEnabled(alloc, id, scanner, ctx), + .addScriptToEvaluateOnNewDocument => addScriptToEvaluateOnNewDocument(alloc, id, scanner, ctx), + .createIsolatedWorld => createIsolatedWorld(alloc, id, scanner, ctx), + .navigate => navigate(alloc, id, scanner, ctx), + }; +} + +fn enable( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const msg = try getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "page.enable" }); + + return result(alloc, msg.id, null, null, msg.sessionID); +} + +const Frame = struct { + id: []const u8, + loaderId: []const u8, + url: []const u8, + domainAndRegistry: []const u8 = "", + securityOrigin: []const u8, + mimeType: []const u8 = "text/html", + adFrameStatus: struct { + adFrameType: []const u8 = "none", + } = .{}, + secureContextType: []const u8, + crossOriginIsolatedContextType: []const u8 = "NotIsolated", + gatedAPIFeatures: [][]const u8 = &[0][]const u8{}, +}; + +fn getFrameTree( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const msg = try cdp.getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "page.getFrameTree" }); + + // output + const FrameTree = struct { + frameTree: struct { + frame: Frame, + }, + childFrames: ?[]@This() = null, + + pub fn format( + self: @This(), + comptime _: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try writer.writeAll("cdp.page.getFrameTree { "); + try writer.writeAll(".frameTree = { "); + try writer.writeAll(".frame = { "); + const frame = self.frameTree.frame; + try writer.writeAll(".id = "); + try std.fmt.formatText(frame.id, "s", options, writer); + try writer.writeAll(", .loaderId = "); + try std.fmt.formatText(frame.loaderId, "s", options, writer); + try writer.writeAll(", .url = "); + try std.fmt.formatText(frame.url, "s", options, writer); + try writer.writeAll(" } } }"); + } + }; + const frameTree = FrameTree{ + .frameTree = .{ + .frame = .{ + .id = ctx.state.frameID, + .url = ctx.state.url, + .securityOrigin = ctx.state.securityOrigin, + .secureContextType = ctx.state.secureContextType, + .loaderId = ctx.state.loaderID, + }, + }, + }; + return result(alloc, msg.id, FrameTree, frameTree, msg.sessionID); +} + +fn setLifecycleEventsEnabled( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + enabled: bool, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "page.setLifecycleEventsEnabled" }); + + ctx.state.page_life_cycle_events = true; + + // output + return result(alloc, msg.id, null, null, msg.sessionID); +} + +const LifecycleEvent = struct { + frameId: []const u8, + loaderId: ?[]const u8, + name: []const u8 = undefined, + timestamp: f32 = undefined, +}; + +// TODO: hard coded method +fn addScriptToEvaluateOnNewDocument( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + source: []const u8, + worldName: ?[]const u8 = null, + includeCommandLineAPI: bool = false, + runImmediately: bool = false, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "page.addScriptToEvaluateOnNewDocument" }); + + // output + const Res = struct { + identifier: []const u8 = "1", + + pub fn format( + self: @This(), + comptime _: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try writer.writeAll("cdp.page.addScriptToEvaluateOnNewDocument { "); + try writer.writeAll(".identifier = "); + try std.fmt.formatText(self.identifier, "s", options, writer); + try writer.writeAll(" }"); + } + }; + return result(alloc, msg.id, Res, Res{}, msg.sessionID); +} + +// TODO: hard coded method +fn createIsolatedWorld( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + frameId: []const u8, + worldName: []const u8, + grantUniveralAccess: bool, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + std.debug.assert(msg.sessionID != null); + log.debug("Req > id {d}, method {s}", .{ msg.id, "page.createIsolatedWorld" }); + const params = msg.params.?; + + // noop executionContextCreated event + try Runtime.executionContextCreated( + alloc, + ctx, + 0, + "", + params.worldName, + // TODO: hard coded ID + "7102379147004877974.3265385113993241162", + .{ + .isDefault = false, + .type = "isolated", + .frameId = params.frameId, + }, + msg.sessionID, + ); + + // output + const Resp = struct { + executionContextId: u8 = 0, + }; + + return result(alloc, msg.id, Resp, .{}, msg.sessionID); +} + +fn navigate( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + url: []const u8, + referrer: ?[]const u8 = null, + transitionType: ?[]const u8 = null, // TODO: enum + frameId: ?[]const u8 = null, + referrerPolicy: ?[]const u8 = null, // TODO: enum + }; + const msg = try getMsg(alloc, _id, Params, scanner); + std.debug.assert(msg.sessionID != null); + log.debug("Req > id {d}, method {s}", .{ msg.id, "page.navigate" }); + const params = msg.params.?; + + // change state + ctx.state.url = params.url; + // TODO: hard coded ID + ctx.state.loaderID = "AF8667A203C5392DBE9AC290044AA4C2"; + + var life_event = LifecycleEvent{ + .frameId = ctx.state.frameID, + .loaderId = ctx.state.loaderID, + }; + var ts_event: cdp.TimestampEvent = undefined; + + // frameStartedLoading event + // TODO: event partially hard coded + const FrameStartedLoading = struct { + frameId: []const u8, + }; + const frame_started_loading = FrameStartedLoading{ .frameId = ctx.state.frameID }; + try sendEvent( + alloc, + ctx, + "Page.frameStartedLoading", + FrameStartedLoading, + frame_started_loading, + msg.sessionID, + ); + if (ctx.state.page_life_cycle_events) { + life_event.name = "init"; + life_event.timestamp = 343721.796037; + try sendEvent( + alloc, + ctx, + "Page.lifecycleEvent", + LifecycleEvent, + life_event, + msg.sessionID, + ); + } + + // output + const Resp = struct { + frameId: []const u8, + loaderId: ?[]const u8, + errorText: ?[]const u8 = null, + + pub fn format( + self: @This(), + comptime _: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try writer.writeAll("cdp.page.navigate.Resp { "); + try writer.writeAll(".frameId = "); + try std.fmt.formatText(self.frameId, "s", options, writer); + if (self.loaderId) |loaderId| { + try writer.writeAll(", .loaderId = '"); + try std.fmt.formatText(loaderId, "s", options, writer); + } + try writer.writeAll(" }"); + } + }; + const resp = Resp{ + .frameId = ctx.state.frameID, + .loaderId = ctx.state.loaderID, + }; + const res = try result(alloc, msg.id, Resp, resp, msg.sessionID); + defer alloc.free(res); + try server.sendSync(ctx, res); + + // TODO: at this point do we need async the following actions to be async? + + // Send Runtime.executionContextsCleared event + // TODO: noop event, we have no env context at this point, is it necesarry? + try sendEvent(alloc, ctx, "Runtime.executionContextsCleared", void, {}, msg.sessionID); + + // Launch navigate + const p = try ctx.browser.session.createPage(); + ctx.state.executionContextId += 1; + const auxData = try std.fmt.allocPrint( + alloc, + // NOTE: we assume this is the default web page + "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", + .{ctx.state.frameID}, + ); + defer alloc.free(auxData); + try p.navigate(params.url, auxData); + + // Events + + // lifecycle init event + // TODO: partially hard coded + if (ctx.state.page_life_cycle_events) { + life_event.name = "init"; + life_event.timestamp = 343721.796037; + try sendEvent( + alloc, + ctx, + "Page.lifecycleEvent", + LifecycleEvent, + life_event, + msg.sessionID, + ); + } + + // frameNavigated event + const FrameNavigated = struct { + frame: Frame, + type: []const u8 = "Navigation", + }; + const frame_navigated = FrameNavigated{ + .frame = .{ + .id = ctx.state.frameID, + .url = ctx.state.url, + .securityOrigin = ctx.state.securityOrigin, + .secureContextType = ctx.state.secureContextType, + .loaderId = ctx.state.loaderID, + }, + }; + try sendEvent( + alloc, + ctx, + "Page.frameNavigated", + FrameNavigated, + frame_navigated, + msg.sessionID, + ); + + // domContentEventFired event + // TODO: partially hard coded + ts_event = .{ .timestamp = 343721.803338 }; + try sendEvent( + alloc, + ctx, + "Page.domContentEventFired", + cdp.TimestampEvent, + ts_event, + msg.sessionID, + ); + + // lifecycle DOMContentLoaded event + // TODO: partially hard coded + if (ctx.state.page_life_cycle_events) { + life_event.name = "DOMContentLoaded"; + life_event.timestamp = 343721.803338; + try sendEvent( + alloc, + ctx, + "Page.lifecycleEvent", + LifecycleEvent, + life_event, + msg.sessionID, + ); + } + + // loadEventFired event + // TODO: partially hard coded + ts_event = .{ .timestamp = 343721.824655 }; + try sendEvent( + alloc, + ctx, + "Page.loadEventFired", + cdp.TimestampEvent, + ts_event, + msg.sessionID, + ); + + // lifecycle DOMContentLoaded event + // TODO: partially hard coded + if (ctx.state.page_life_cycle_events) { + life_event.name = "load"; + life_event.timestamp = 343721.824655; + try sendEvent( + alloc, + ctx, + "Page.lifecycleEvent", + LifecycleEvent, + life_event, + msg.sessionID, + ); + } + + // frameStoppedLoading + const FrameStoppedLoading = struct { frameId: []const u8 }; + try sendEvent( + alloc, + ctx, + "Page.frameStoppedLoading", + FrameStoppedLoading, + .{ .frameId = ctx.state.frameID }, + msg.sessionID, + ); + + return ""; +} diff --git a/src/cdp/performance.zig b/src/cdp/performance.zig new file mode 100644 index 00000000..ce8e670d --- /dev/null +++ b/src/cdp/performance.zig @@ -0,0 +1,60 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; +const cdp = @import("cdp.zig"); +const result = cdp.result; +const getMsg = cdp.getMsg; + +const log = std.log.scoped(.cdp); + +const Methods = enum { + enable, +}; + +pub fn performance( + alloc: std.mem.Allocator, + id: ?u16, + action: []const u8, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + const method = std.meta.stringToEnum(Methods, action) orelse + return error.UnknownMethod; + + return switch (method) { + .enable => enable(alloc, id, scanner, ctx), + }; +} + +fn enable( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const msg = try getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "performance.enable" }); + + return result(alloc, msg.id, null, null, msg.sessionID); +} diff --git a/src/cdp/runtime.zig b/src/cdp/runtime.zig new file mode 100644 index 00000000..d2723044 --- /dev/null +++ b/src/cdp/runtime.zig @@ -0,0 +1,179 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const builtin = @import("builtin"); + +const jsruntime = @import("jsruntime"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; +const cdp = @import("cdp.zig"); +const result = cdp.result; +const getMsg = cdp.getMsg; +const stringify = cdp.stringify; + +const log = std.log.scoped(.cdp); + +const Methods = enum { + enable, + runIfWaitingForDebugger, + evaluate, + addBinding, + callFunctionOn, + releaseObject, +}; + +pub fn runtime( + alloc: std.mem.Allocator, + id: ?u16, + action: []const u8, + scanner: *std.json.Scanner, + s: []const u8, + ctx: *Ctx, +) ![]const u8 { + const method = std.meta.stringToEnum(Methods, action) orelse + // NOTE: we could send it anyway to the JS runtime but it's good to check it + return error.UnknownMethod; + return switch (method) { + .runIfWaitingForDebugger => runIfWaitingForDebugger(alloc, id, scanner, ctx), + else => sendInspector(alloc, method, id, s, scanner, ctx), + }; +} + +fn sendInspector( + alloc: std.mem.Allocator, + method: Methods, + _id: ?u16, + s: []const u8, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // save script in file at debug mode + if (std.log.defaultLogEnabled(.debug)) { + + // input + var script: ?[]const u8 = null; + var id: u16 = undefined; + + if (method == .evaluate) { + const Params = struct { + expression: []const u8, + contextId: ?u8 = null, + returnByValue: ?bool = null, + awaitPromise: ?bool = null, + userGesture: ?bool = null, + }; + + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s} (script saved on cache)", .{ msg.id, "runtime.evaluate" }); + const params = msg.params.?; + script = params.expression; + id = msg.id; + } else if (method == .callFunctionOn) { + const Params = struct { + functionDeclaration: []const u8, + objectId: ?[]const u8 = null, + executionContextId: ?u8 = null, + arguments: ?[]struct { + value: ?[]const u8 = null, + objectId: ?[]const u8 = null, + } = null, + returnByValue: ?bool = null, + awaitPromise: ?bool = null, + userGesture: ?bool = null, + }; + + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s} (script saved on cache)", .{ msg.id, "runtime.callFunctionOn" }); + const params = msg.params.?; + script = params.functionDeclaration; + id = msg.id; + } + + if (script) |src| { + try cdp.dumpFile(alloc, id, src); + } + } + + // remove awaitPromise true params + // TODO: delete when Promise are correctly handled by zig-js-runtime + if (method == .callFunctionOn or method == .evaluate) { + const buf = try alloc.alloc(u8, s.len + 1); + defer alloc.free(buf); + _ = std.mem.replace(u8, s, "\"awaitPromise\":true", "\"awaitPromise\":false", buf); + ctx.sendInspector(buf); + } else { + ctx.sendInspector(s); + } + return ""; +} + +pub const AuxData = struct { + isDefault: bool = true, + type: []const u8 = "default", + frameId: []const u8 = cdp.FrameID, +}; + +pub fn executionContextCreated( + alloc: std.mem.Allocator, + ctx: *Ctx, + id: u16, + origin: []const u8, + name: []const u8, + uniqueID: []const u8, + auxData: ?AuxData, + sessionID: ?[]const u8, +) !void { + const Params = struct { + context: struct { + id: u64, + origin: []const u8, + name: []const u8, + uniqueId: []const u8, + auxData: ?AuxData = null, + }, + }; + const params = Params{ + .context = .{ + .id = id, + .origin = origin, + .name = name, + .uniqueId = uniqueID, + .auxData = auxData, + }, + }; + try cdp.sendEvent(alloc, ctx, "Runtime.executionContextCreated", Params, params, sessionID); +} + +// TODO: noop method +// should we be passing this also to the JS Inspector? +fn runIfWaitingForDebugger( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const msg = try getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "runtime.runIfWaitingForDebugger" }); + + return result(alloc, msg.id, null, null, msg.sessionID); +} diff --git a/src/cdp/target.zig b/src/cdp/target.zig new file mode 100644 index 00000000..ba0e977c --- /dev/null +++ b/src/cdp/target.zig @@ -0,0 +1,382 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const server = @import("../server.zig"); +const Ctx = server.Ctx; +const cdp = @import("cdp.zig"); +const result = cdp.result; +const getMsg = cdp.getMsg; +const stringify = cdp.stringify; + +const log = std.log.scoped(.cdp); + +const Methods = enum { + setDiscoverTargets, + setAutoAttach, + getTargetInfo, + getBrowserContexts, + createBrowserContext, + disposeBrowserContext, + createTarget, + closeTarget, +}; + +pub fn target( + alloc: std.mem.Allocator, + id: ?u16, + action: []const u8, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + const method = std.meta.stringToEnum(Methods, action) orelse + return error.UnknownMethod; + return switch (method) { + .setDiscoverTargets => setDiscoverTargets(alloc, id, scanner, ctx), + .setAutoAttach => setAutoAttach(alloc, id, scanner, ctx), + .getTargetInfo => getTargetInfo(alloc, id, scanner, ctx), + .getBrowserContexts => getBrowserContexts(alloc, id, scanner, ctx), + .createBrowserContext => createBrowserContext(alloc, id, scanner, ctx), + .disposeBrowserContext => disposeBrowserContext(alloc, id, scanner, ctx), + .createTarget => createTarget(alloc, id, scanner, ctx), + .closeTarget => closeTarget(alloc, id, scanner, ctx), + }; +} + +// TODO: hard coded IDs +const PageTargetID = "CFCD6EC01573CF29BB638E9DC0F52DDC"; +const BrowserTargetID = "2d2bdef9-1c95-416f-8c0e-83f3ab73a30c"; +const BrowserContextID = "65618675CB7D3585A95049E9DFE95EA9"; + +// TODO: noop method +fn setDiscoverTargets( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const msg = try getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "target.setDiscoverTargets" }); + + // output + return result(alloc, msg.id, null, null, msg.sessionID); +} + +const AttachToTarget = struct { + sessionId: []const u8, + targetInfo: struct { + targetId: []const u8, + type: []const u8 = "page", + title: []const u8, + url: []const u8, + attached: bool = true, + canAccessOpener: bool = false, + browserContextId: []const u8, + }, + waitingForDebugger: bool = false, +}; + +const TargetFilter = struct { + type: ?[]const u8 = null, + exclude: ?bool = null, +}; + +// TODO: noop method +fn setAutoAttach( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + autoAttach: bool, + waitForDebuggerOnStart: bool, + flatten: bool = true, + filter: ?[]TargetFilter = null, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "target.setAutoAttach" }); + + // attachedToTarget event + if (msg.sessionID == null) { + const attached = AttachToTarget{ + .sessionId = cdp.BrowserSessionID, + .targetInfo = .{ + .targetId = PageTargetID, + .title = "New Incognito tab", + .url = cdp.URLBase, + .browserContextId = BrowserContextID, + }, + }; + try cdp.sendEvent(alloc, ctx, "Target.attachedToTarget", AttachToTarget, attached, null); + } + + // output + return result(alloc, msg.id, null, null, msg.sessionID); +} + +fn getTargetInfo( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + _: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + targetId: ?[]const u8 = null, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "target.getTargetInfo" }); + + // output + const TargetInfo = struct { + targetId: []const u8, + type: []const u8, + title: []const u8 = "", + url: []const u8 = "", + attached: bool = true, + openerId: ?[]const u8 = null, + canAccessOpener: bool = false, + openerFrameId: ?[]const u8 = null, + browserContextId: ?[]const u8 = null, + subtype: ?[]const u8 = null, + }; + const targetInfo = TargetInfo{ + .targetId = BrowserTargetID, + .type = "browser", + }; + return result(alloc, msg.id, TargetInfo, targetInfo, null); +} + +// Browser context are not handled and not in the roadmap for now +// The following methods are "fake" + +// TODO: noop method +fn getBrowserContexts( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const msg = try getMsg(alloc, _id, void, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "target.getBrowserContexts" }); + + // ouptut + const Resp = struct { + browserContextIds: [][]const u8, + }; + var resp: Resp = undefined; + if (ctx.state.contextID) |contextID| { + var contextIDs = [1][]const u8{contextID}; + resp = .{ .browserContextIds = &contextIDs }; + } else { + const contextIDs = [0][]const u8{}; + resp = .{ .browserContextIds = &contextIDs }; + } + return result(alloc, msg.id, Resp, resp, null); +} + +const ContextID = "22648B09EDCCDD11109E2D4FEFBE4F89"; + +// TODO: noop method +fn createBrowserContext( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + disposeOnDetach: bool = false, + proxyServer: ?[]const u8 = null, + proxyBypassList: ?[]const u8 = null, + originsWithUniversalNetworkAccess: ?[][]const u8 = null, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "target.createBrowserContext" }); + + ctx.state.contextID = ContextID; + + // output + const Resp = struct { + browserContextId: []const u8 = ContextID, + + pub fn format( + self: @This(), + comptime _: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try writer.writeAll("cdp.target.createBrowserContext { "); + try writer.writeAll(".browserContextId = "); + try std.fmt.formatText(self.browserContextId, "s", options, writer); + try writer.writeAll(" }"); + } + }; + return result(alloc, msg.id, Resp, Resp{}, msg.sessionID); +} + +fn disposeBrowserContext( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + browserContextId: []const u8, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "target.disposeBrowserContext" }); + + // output + const res = try result(alloc, msg.id, null, .{}, null); + defer alloc.free(res); + try server.sendSync(ctx, res); + + return error.DisposeBrowserContext; +} + +// TODO: hard coded IDs +const TargetID = "57356548460A8F29706A2ADF14316298"; +const LoaderID = "DD4A76F842AA389647D702B4D805F49A"; + +fn createTarget( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + url: []const u8, + width: ?u64 = null, + height: ?u64 = null, + browserContextId: []const u8, + enableBeginFrameControl: bool = false, + newWindow: bool = false, + background: bool = false, + forTab: ?bool = null, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "target.createTarget" }); + + // change CDP state + ctx.state.frameID = TargetID; + ctx.state.url = "about:blank"; + ctx.state.securityOrigin = "://"; + ctx.state.secureContextType = "InsecureScheme"; + ctx.state.loaderID = LoaderID; + + // send attachToTarget event + const attached = AttachToTarget{ + .sessionId = cdp.ContextSessionID, + .targetInfo = .{ + .targetId = ctx.state.frameID, + .title = "", + .url = ctx.state.url, + .browserContextId = ContextID, + }, + .waitingForDebugger = true, + }; + try cdp.sendEvent(alloc, ctx, "Target.attachedToTarget", AttachToTarget, attached, msg.sessionID); + + // output + const Resp = struct { + targetId: []const u8 = TargetID, + + pub fn format( + self: @This(), + comptime _: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try writer.writeAll("cdp.target.createTarget { "); + try writer.writeAll(".targetId = "); + try std.fmt.formatText(self.targetId, "s", options, writer); + try writer.writeAll(" }"); + } + }; + return result(alloc, msg.id, Resp, Resp{}, msg.sessionID); +} + +fn closeTarget( + alloc: std.mem.Allocator, + _id: ?u16, + scanner: *std.json.Scanner, + ctx: *Ctx, +) ![]const u8 { + + // input + const Params = struct { + targetId: []const u8, + }; + const msg = try getMsg(alloc, _id, Params, scanner); + log.debug("Req > id {d}, method {s}", .{ msg.id, "target.closeTarget" }); + + // output + const Resp = struct { + success: bool = true, + }; + const res = try result(alloc, msg.id, Resp, Resp{}, null); + defer alloc.free(res); + try server.sendSync(ctx, res); + + // Inspector.detached event + const InspectorDetached = struct { + reason: []const u8 = "Render process gone.", + }; + try cdp.sendEvent( + alloc, + ctx, + "Inspector.detached", + InspectorDetached, + .{}, + msg.sessionID orelse cdp.ContextSessionID, + ); + + // detachedFromTarget event + const TargetDetached = struct { + sessionId: []const u8, + targetId: []const u8, + }; + try cdp.sendEvent( + alloc, + ctx, + "Target.detachedFromTarget", + TargetDetached, + .{ + .sessionId = msg.sessionID orelse cdp.ContextSessionID, + .targetId = msg.params.?.targetId, + }, + null, + ); + + return ""; +} diff --git a/src/main.zig b/src/main.zig index 40b513fe..f00fbbc0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -17,97 +17,219 @@ // along with this program. If not, see . const std = @import("std"); +const posix = std.posix; const jsruntime = @import("jsruntime"); +const Browser = @import("browser/browser.zig").Browser; +const server = @import("server.zig"); + const parser = @import("netsurf"); const apiweb = @import("apiweb.zig"); -const Window = @import("html/window.zig").Window; + +const log = std.log.scoped(.server); pub const Types = jsruntime.reflect(apiweb.Interfaces); pub const UserContext = apiweb.UserContext; -const socket_path = "/tmp/browsercore-server.sock"; +// Default options +const Host = "127.0.0.1"; +const Port = 3245; +const Timeout = 3; // in seconds -var doc: *parser.DocumentHTML = undefined; -var server: std.net.Server = undefined; +const usage = + \\usage: {s} [options] + \\ start Lightpanda browser in CDP server mode + \\ + \\ -h, --help Print this help message and exit. + \\ --host Host of the server (default "127.0.0.1") + \\ --port Port of the server (default "3245") + \\ --timeout Timeout for incoming connections in seconds (default "3") + \\ +; -fn execJS( - alloc: std.mem.Allocator, - js_env: *jsruntime.Env, -) anyerror!void { - // start JS env - try js_env.start(); - defer js_env.stop(); +// Inspired by std.net.StreamServer in Zig < 0.12 +pub const StreamServer = struct { + /// Copied from `Options` on `init`. + kernel_backlog: u31, + reuse_address: bool, + reuse_port: bool, + nonblocking: bool, - // alias global as self and window - var window = Window.create(null); - window.replaceDocument(doc); - try js_env.bindGlobal(window); + /// `undefined` until `listen` returns successfully. + listen_address: std.net.Address, - // try catch - var try_catch: jsruntime.TryCatch = undefined; - try_catch.init(js_env.*); - defer try_catch.deinit(); + sockfd: ?posix.socket_t, - while (true) { + pub const Options = struct { + /// How many connections the kernel will accept on the application's behalf. + /// If more than this many connections pool in the kernel, clients will start + /// seeing "Connection refused". + kernel_backlog: u31 = 128, - // read cmd - const conn = try server.accept(); - var buf: [100]u8 = undefined; - const read = try conn.stream.read(&buf); - const cmd = buf[0..read]; - std.debug.print("<- {s}\n", .{cmd}); - if (std.mem.eql(u8, cmd, "exit")) { - break; + /// Enable SO.REUSEADDR on the socket. + reuse_address: bool = false, + + /// Enable SO.REUSEPORT on the socket. + reuse_port: bool = false, + + /// Non-blocking mode. + nonblocking: bool = false, + }; + + /// After this call succeeds, resources have been acquired and must + /// be released with `deinit`. + pub fn init(options: Options) StreamServer { + return StreamServer{ + .sockfd = null, + .kernel_backlog = options.kernel_backlog, + .reuse_address = options.reuse_address, + .reuse_port = options.reuse_port, + .nonblocking = options.nonblocking, + .listen_address = undefined, + }; + } + + /// Release all resources. The `StreamServer` memory becomes `undefined`. + pub fn deinit(self: *StreamServer) void { + self.close(); + self.* = undefined; + } + + pub fn listen(self: *StreamServer, address: std.net.Address) !void { + const sock_flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC; + var use_sock_flags: u32 = sock_flags; + if (self.nonblocking) use_sock_flags |= posix.SOCK.NONBLOCK; + const proto = if (address.any.family == posix.AF.UNIX) @as(u32, 0) else posix.IPPROTO.TCP; + + const sockfd = try posix.socket(address.any.family, use_sock_flags, proto); + self.sockfd = sockfd; + errdefer { + posix.close(sockfd); + self.sockfd = null; } - const res = try js_env.exec(cmd, "cdp"); - const res_str = try res.toString(alloc, js_env.*); - defer alloc.free(res_str); - std.debug.print("-> {s}\n", .{res_str}); + if (self.reuse_address) { + try posix.setsockopt( + sockfd, + posix.SOL.SOCKET, + posix.SO.REUSEADDR, + &std.mem.toBytes(@as(c_int, 1)), + ); + } + if (@hasDecl(posix.SO, "REUSEPORT") and self.reuse_port) { + try posix.setsockopt( + sockfd, + posix.SOL.SOCKET, + posix.SO.REUSEPORT, + &std.mem.toBytes(@as(c_int, 1)), + ); + } - _ = try conn.stream.write(res_str); + var socklen = address.getOsSockLen(); + try posix.bind(sockfd, &address.any, socklen); + try posix.listen(sockfd, self.kernel_backlog); + try posix.getsockname(sockfd, &self.listen_address.any, &socklen); } + + /// Stop listening. It is still necessary to call `deinit` after stopping listening. + /// Calling `deinit` will automatically call `close`. It is safe to call `close` when + /// not listening. + pub fn close(self: *StreamServer) void { + if (self.sockfd) |fd| { + posix.close(fd); + self.sockfd = null; + self.listen_address = undefined; + } + } +}; + +fn printUsageExit(execname: []const u8, res: u8) void { + std.io.getStdErr().writer().print(usage, .{execname}) catch |err| { + log.err("Print usage error: {any}", .{err}); + std.posix.exit(1); + }; + std.posix.exit(res); } pub fn main() !void { - // create v8 vm - const vm = jsruntime.VM.init(); - defer vm.deinit(); - - // alloc + // allocator var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); - try parser.init(); - defer parser.deinit(); + // args + var args = try std.process.argsWithAllocator(arena.allocator()); + defer args.deinit(); - // document - const file = try std.fs.cwd().openFile("test.html", .{}); - defer file.close(); + const execname = args.next().?; + var host: []const u8 = Host; + var port: u16 = Port; + var addr: std.net.Address = undefined; + var timeout: u8 = undefined; - doc = try parser.documentHTMLParse(file.reader(), "UTF-8"); - defer parser.documentHTMLClose(doc) catch |err| { - std.debug.print("documentHTMLClose error: {s}\n", .{@errorName(err)}); - }; - - // remove socket file of internal server - // reuse_address (SO_REUSEADDR flag) does not seems to work on unix socket - // see: https://gavv.net/articles/unix-socket-reuse/ - // TODO: use a lock file instead - std.posix.unlink(socket_path) catch |err| { - if (err != error.FileNotFound) { - return err; + while (args.next()) |opt| { + if (std.mem.eql(u8, "-h", opt) or std.mem.eql(u8, "--help", opt)) { + printUsageExit(execname, 0); } + if (std.mem.eql(u8, "--host", opt)) { + if (args.next()) |arg| { + host = arg; + continue; + } else { + log.err("--host not provided\n", .{}); + return printUsageExit(execname, 1); + } + } + if (std.mem.eql(u8, "--port", opt)) { + if (args.next()) |arg| { + port = std.fmt.parseInt(u16, arg, 10) catch |err| { + log.err("--port {any}\n", .{err}); + return printUsageExit(execname, 1); + }; + continue; + } else { + log.err("--port not provided\n", .{}); + return printUsageExit(execname, 1); + } + } + if (std.mem.eql(u8, "--timeout", opt)) { + if (args.next()) |arg| { + timeout = std.fmt.parseInt(u8, arg, 10) catch |err| { + log.err("--timeout {any}\n", .{err}); + return printUsageExit(execname, 1); + }; + continue; + } else { + log.err("--timeout not provided\n", .{}); + return printUsageExit(execname, 1); + } + } + } + addr = std.net.Address.parseIp4(host, port) catch |err| { + log.err("address (host:port) {any}\n", .{err}); + return printUsageExit(execname, 1); }; // server - const addr = try std.net.Address.initUnix(socket_path); - server = try addr.listen(.{}); - defer server.deinit(); - std.debug.print("Listening on: {s}...\n", .{socket_path}); + var srv = StreamServer.init(.{ + .reuse_address = true, + .reuse_port = true, + .nonblocking = true, + }); + defer srv.deinit(); - try jsruntime.loadEnv(&arena, null, execJS); + srv.listen(addr) catch |err| { + log.err("address (host:port) {any}\n", .{err}); + return printUsageExit(execname, 1); + }; + defer srv.close(); + log.info("Listening on: {s}:{d}...", .{ host, port }); + + // loop + var loop = try jsruntime.Loop.init(arena.allocator()); + defer loop.deinit(); + + // listen + try server.listen(arena.allocator(), &loop, srv.sockfd.?, std.time.ns_per_s * @as(u64, timeout)); } diff --git a/src/main_get.zig b/src/main_get.zig index 6f455553..1607b95a 100644 --- a/src/main_get.zig +++ b/src/main_get.zig @@ -80,14 +80,16 @@ pub fn main() !void { const vm = jsruntime.VM.init(); defer vm.deinit(); - var browser = try Browser.init(allocator, vm); + var loop = try jsruntime.Loop.init(allocator); + defer loop.deinit(); + + var browser = Browser{}; + try Browser.init(&browser, allocator, &loop, vm); defer browser.deinit(); - var page = try browser.currentSession().createPage(); - defer page.deinit(); + const page = try browser.session.createPage(); - try page.navigate(url); - defer page.end(); + try page.navigate(url, null); try page.wait(); diff --git a/src/msg.zig b/src/msg.zig new file mode 100644 index 00000000..c7b6d353 --- /dev/null +++ b/src/msg.zig @@ -0,0 +1,182 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +/// MsgBuffer returns messages from a raw text read stream, +/// according to the following format `:`. +/// It handles both: +/// - combined messages in one read +/// - single message in several reads (multipart) +/// It's safe (and a good practice) to reuse the same MsgBuffer +/// on several reads of the same stream. +pub const MsgBuffer = struct { + size: usize = 0, + buf: []u8, + pos: usize = 0, + + const MaxSize = 1024 * 1024; // 1MB + + pub fn init(alloc: std.mem.Allocator, size: usize) std.mem.Allocator.Error!MsgBuffer { + const buf = try alloc.alloc(u8, size); + return .{ .buf = buf }; + } + + pub fn deinit(self: MsgBuffer, alloc: std.mem.Allocator) void { + alloc.free(self.buf); + } + + fn isFinished(self: *MsgBuffer) bool { + return self.pos >= self.size; + } + + fn isEmpty(self: MsgBuffer) bool { + return self.size == 0 and self.pos == 0; + } + + fn reset(self: *MsgBuffer) void { + self.size = 0; + self.pos = 0; + } + + // read input + // - `do_func` is a callback to execute on each message of the input + // - `data` is an arbitrary user data that will be forwarded to the do_func callback + pub fn read( + self: *MsgBuffer, + alloc: std.mem.Allocator, + input: []const u8, + data: anytype, + comptime do_func: fn (data: @TypeOf(data), msg: []const u8) anyerror!void, + ) !void { + var _input = input; // make input writable + + while (true) { + var msg: []const u8 = undefined; + + // msg size + var msg_size: usize = undefined; + if (self.isEmpty()) { + // parse msg size metadata + const size_pos = std.mem.indexOfScalar(u8, _input, ':') orelse return error.InputWithoutSize; + const size_str = _input[0..size_pos]; + msg_size = try std.fmt.parseInt(u32, size_str, 10); + _input = _input[size_pos + 1 ..]; + } else { + msg_size = self.size; + } + + // multipart + const is_multipart = !self.isEmpty() or _input.len < msg_size; + if (is_multipart) { + + // set msg size on empty MsgBuffer + if (self.isEmpty()) { + self.size = msg_size; + } + + // get the new position of the cursor + const new_pos = self.pos + _input.len; + + // check max limit size + if (new_pos > MaxSize) { + return error.MsgTooBig; + } + + // check if the current input can fit in MsgBuffer + if (new_pos > self.buf.len) { + // we want to realloc at least: + // - a size big enough to fit the entire input (ie. new_pos) + // - a size big enough (ie. current size + starting size) + // to avoid multiple reallocation + const new_size = @max(self.buf.len + self.size, new_pos); + // resize the MsgBuffer to fit + self.buf = try alloc.realloc(self.buf, new_size); + } + + // copy the current input into MsgBuffer + @memcpy(self.buf[self.pos..new_pos], _input[0..]); + + // set the new cursor position + self.pos = new_pos; + + // if multipart is not finished, go fetch the next input + if (!self.isFinished()) return; + + // otherwhise multipart is finished, use its buffer as input + _input = self.buf[0..self.pos]; + self.reset(); + } + + // handle several JSON msg in 1 read + const is_combined = _input.len > msg_size; + msg = _input[0..msg_size]; + if (is_combined) { + _input = _input[msg_size..]; + } + + try @call(.auto, do_func, .{ data, msg }); + + if (!is_combined) break; + } + } +}; + +fn doTest(nb: *u8, _: []const u8) anyerror!void { + nb.* += 1; +} + +test "MsgBuffer" { + const Case = struct { + input: []const u8, + nb: u8, + }; + const alloc = std.testing.allocator; + const cases = [_]Case{ + // simple + .{ .input = "2:ok", .nb = 1 }, + // combined + .{ .input = "2:ok3:foo7:bar2:ok", .nb = 3 }, // "bar2:ok" is a message, no need to escape "2:" here + // multipart + .{ .input = "9:multi", .nb = 0 }, + .{ .input = "part", .nb = 1 }, + // multipart & combined + .{ .input = "9:multi", .nb = 0 }, + .{ .input = "part2:ok", .nb = 2 }, + // multipart & combined with other multipart + .{ .input = "9:multi", .nb = 0 }, + .{ .input = "part8:co", .nb = 1 }, + .{ .input = "mbined", .nb = 1 }, + // several multipart + .{ .input = "23:multi", .nb = 0 }, + .{ .input = "several", .nb = 0 }, + .{ .input = "complex", .nb = 0 }, + .{ .input = "part", .nb = 1 }, + // combined & multipart + .{ .input = "2:ok9:multi", .nb = 1 }, + .{ .input = "part", .nb = 1 }, + }; + var nb: u8 = undefined; + var msg_buf = try MsgBuffer.init(alloc, 10); + defer msg_buf.deinit(alloc); + for (cases) |case| { + nb = 0; + try msg_buf.read(alloc, case.input, &nb, doTest); + try std.testing.expect(nb == case.nb); + } +} diff --git a/src/run_tests.zig b/src/run_tests.zig index 3b235677..d3f8fb99 100644 --- a/src/run_tests.zig +++ b/src/run_tests.zig @@ -295,6 +295,9 @@ const kb = 1024; const ms = std.time.ns_per_ms; test { + const msgTest = @import("msg.zig"); + std.testing.refAllDecls(msgTest); + const asyncTest = @import("async/test.zig"); std.testing.refAllDecls(asyncTest); diff --git a/src/server.zig b/src/server.zig new file mode 100644 index 00000000..2b8ee379 --- /dev/null +++ b/src/server.zig @@ -0,0 +1,468 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const jsruntime = @import("jsruntime"); +const Completion = jsruntime.IO.Completion; +const AcceptError = jsruntime.IO.AcceptError; +const RecvError = jsruntime.IO.RecvError; +const SendError = jsruntime.IO.SendError; +const CloseError = jsruntime.IO.CloseError; +const TimeoutError = jsruntime.IO.TimeoutError; + +const MsgBuffer = @import("msg.zig").MsgBuffer; +const Browser = @import("browser/browser.zig").Browser; +const cdp = @import("cdp/cdp.zig"); + +const NoError = error{NoError}; +const IOError = AcceptError || RecvError || SendError || CloseError || TimeoutError; +const Error = IOError || std.fmt.ParseIntError || cdp.Error || NoError; + +const TimeoutCheck = std.time.ns_per_ms * 100; + +const log = std.log.scoped(.server); + +// I/O Main +// -------- + +const BufReadSize = 1024; // 1KB +const MaxStdOutSize = 512; // ensure debug msg are not too long + +pub const Ctx = struct { + loop: *jsruntime.Loop, + + // internal fields + accept_socket: std.posix.socket_t, + conn_socket: std.posix.socket_t = undefined, + read_buf: []u8, // only for read operations + msg_buf: *MsgBuffer, + err: ?Error = null, + + // I/O fields + conn_completion: *Completion, + timeout_completion: *Completion, + timeout: u64, + last_active: ?std.time.Instant = null, + + // CDP + state: cdp.State = .{}, + + // JS fields + browser: *Browser, // TODO: is pointer mandatory here? + sessionNew: bool, + // try_catch: jsruntime.TryCatch, // TODO + + // callbacks + // --------- + + fn acceptCbk( + self: *Ctx, + completion: *Completion, + result: AcceptError!std.posix.socket_t, + ) void { + std.debug.assert(completion == self.conn_completion); + + self.conn_socket = result catch |err| { + self.err = err; + return; + }; + + // set connection timestamp and timeout + self.last_active = std.time.Instant.now() catch |err| { + log.err("accept timestamp error: {any}", .{err}); + return; + }; + self.loop.io.timeout( + *Ctx, + self, + Ctx.timeoutCbk, + self.timeout_completion, + TimeoutCheck, + ); + + // receving incomming messages asynchronously + self.loop.io.recv( + *Ctx, + self, + Ctx.readCbk, + self.conn_completion, + self.conn_socket, + self.read_buf, + ); + } + + fn readCbk(self: *Ctx, completion: *Completion, result: RecvError!usize) void { + std.debug.assert(completion == self.conn_completion); + + const size = result catch |err| { + self.err = err; + return; + }; + + if (size == 0) { + // continue receving incomming messages asynchronously + self.loop.io.recv( + *Ctx, + self, + Ctx.readCbk, + self.conn_completion, + self.conn_socket, + self.read_buf, + ); + return; + } + + // input + const input = self.read_buf[0..size]; + + // read and execute input + self.msg_buf.read(self.alloc(), input, self, Ctx.do) catch |err| { + if (err != error.Closed) { + log.err("do error: {any}", .{err}); + } + return; + }; + + // set connection timestamp + self.last_active = std.time.Instant.now() catch |err| { + log.err("read timestamp error: {any}", .{err}); + return; + }; + + // continue receving incomming messages asynchronously + self.loop.io.recv( + *Ctx, + self, + Ctx.readCbk, + self.conn_completion, + self.conn_socket, + self.read_buf, + ); + } + + fn timeoutCbk(self: *Ctx, completion: *Completion, result: TimeoutError!void) void { + std.debug.assert(completion == self.timeout_completion); + + _ = result catch |err| { + self.err = err; + return; + }; + + if (self.isClosed()) { + // conn is already closed, ignore timeout + return; + } + + // check time since last read + const now = std.time.Instant.now() catch |err| { + log.err("timeout timestamp error: {any}", .{err}); + return; + }; + + if (now.since(self.last_active.?) > self.timeout) { + // closing + log.debug("conn timeout, closing...", .{}); + + // NOTE: we should cancel the current read + // but it seems that's just closing the connection is enough + // (and cancel does not work on MacOS) + + // close current connection + self.loop.io.close( + *Ctx, + self, + Ctx.closeCbk, + self.timeout_completion, + self.conn_socket, + ); + return; + } + + // continue checking timeout + self.loop.io.timeout( + *Ctx, + self, + Ctx.timeoutCbk, + self.timeout_completion, + TimeoutCheck, + ); + } + + fn closeCbk(self: *Ctx, completion: *Completion, result: CloseError!void) void { + _ = completion; + // NOTE: completion can be either self.conn_completion or self.timeout_completion + + _ = result catch |err| { + self.err = err; + return; + }; + + // conn is closed + self.last_active = null; + + // restart a new browser session in case of re-connect + if (!self.sessionNew) { + self.newSession() catch |err| { + log.err("new session error: {any}", .{err}); + return; + }; + } + + log.info("accepting new conn...", .{}); + + // continue accepting incoming requests + self.loop.io.accept( + *Ctx, + self, + Ctx.acceptCbk, + self.conn_completion, + self.accept_socket, + ); + } + + // shortcuts + // --------- + + inline fn isClosed(self: *Ctx) bool { + // last_active is first saved on acceptCbk + return self.last_active == null; + } + + // allocator of the current session + inline fn alloc(self: *Ctx) std.mem.Allocator { + return self.browser.session.alloc; + } + + // JS env of the current session + inline fn env(self: Ctx) jsruntime.Env { + return self.browser.session.env; + } + + // actions + // ------- + + fn do(self: *Ctx, cmd: []const u8) anyerror!void { + + // close cmd + if (std.mem.eql(u8, cmd, "close")) { + // close connection + log.info("close cmd, closing conn...", .{}); + self.loop.io.close( + *Ctx, + self, + Ctx.closeCbk, + self.conn_completion, + self.conn_socket, + ); + return error.Closed; + } + + if (self.sessionNew) self.sessionNew = false; + + const res = cdp.do(self.alloc(), cmd, self) catch |err| { + + // cdp end cmd + if (err == error.DisposeBrowserContext) { + // restart a new browser session + std.log.scoped(.cdp).debug("end cmd, restarting a new session...", .{}); + try self.newSession(); + return; + } + + return err; + }; + + // send result + if (!std.mem.eql(u8, res, "")) { + return sendAsync(self, res); + } + } + + fn newSession(self: *Ctx) !void { + try self.browser.newSession(self.alloc(), self.loop); + try self.browser.session.initInspector( + self, + Ctx.onInspectorResp, + Ctx.onInspectorNotif, + ); + self.sessionNew = true; + } + + // inspector + // --------- + + pub fn sendInspector(self: *Ctx, msg: []const u8) void { + if (self.env().getInspector()) |inspector| { + inspector.send(self.env(), msg); + } else @panic("Inspector has not been set"); + } + + inline fn inspectorCtx(ctx_opaque: *anyopaque) *Ctx { + const aligned = @as(*align(@alignOf(Ctx)) anyopaque, @alignCast(ctx_opaque)); + return @as(*Ctx, @ptrCast(aligned)); + } + + fn inspectorMsg(allocator: std.mem.Allocator, ctx: *Ctx, msg: []const u8) !void { + // inject sessionID in cdp msg + const tpl = "{s},\"sessionId\":\"{s}\"}}"; + const msg_open = msg[0 .. msg.len - 1]; // remove closing bracket + const s = try std.fmt.allocPrint( + allocator, + tpl, + .{ msg_open, cdp.ContextSessionID }, + ); + defer ctx.alloc().free(s); + + try sendSync(ctx, s); + } + + pub fn onInspectorResp(ctx_opaque: *anyopaque, _: u32, msg: []const u8) void { + if (std.log.defaultLogEnabled(.debug)) { + // msg should be {"id":,... + const id_end = std.mem.indexOfScalar(u8, msg, ',') orelse unreachable; + const id = msg[6..id_end]; + std.log.scoped(.cdp).debug("Res (inspector) > id {s}", .{id}); + } + const ctx = inspectorCtx(ctx_opaque); + inspectorMsg(ctx.alloc(), ctx, msg) catch unreachable; + } + + pub fn onInspectorNotif(ctx_opaque: *anyopaque, msg: []const u8) void { + if (std.log.defaultLogEnabled(.debug)) { + // msg should be {"method":,... + const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse unreachable; + const method = msg[10..method_end]; + std.log.scoped(.cdp).debug("Event (inspector) > method {s}", .{method}); + } + const ctx = inspectorCtx(ctx_opaque); + inspectorMsg(ctx.alloc(), ctx, msg) catch unreachable; + } +}; + +// I/O Send +// -------- + +// NOTE: to allow concurrent send we create each time a dedicated context +// (with its own completion), allocated on the heap. +// After the send (on the sendCbk) the dedicated context will be destroy +// and the msg slice will be free. +const Send = struct { + ctx: *Ctx, + msg: []const u8, + completion: Completion = undefined, + + fn init(ctx: *Ctx, msg: []const u8) !*Send { + const sd = try ctx.alloc().create(Send); + sd.* = .{ .ctx = ctx, .msg = msg }; + return sd; + } + + fn deinit(self: *Send) void { + self.ctx.alloc().free(self.msg); + self.ctx.alloc().destroy(self); + } + + fn asyncCbk(self: *Send, _: *Completion, result: SendError!usize) void { + _ = result catch |err| { + self.ctx.err = err; + return; + }; + self.deinit(); + } +}; + +pub fn sendAsync(ctx: *Ctx, msg: []const u8) !void { + const sd = try Send.init(ctx, msg); + ctx.loop.io.send(*Send, sd, Send.asyncCbk, &sd.completion, ctx.conn_socket, msg); +} + +pub fn sendSync(ctx: *Ctx, msg: []const u8) !void { + _ = try std.posix.write(ctx.conn_socket, msg); +} + +// Listen +// ------ + +pub fn listen( + alloc: std.mem.Allocator, + loop: *jsruntime.Loop, + server_socket: std.posix.socket_t, + timeout: u64, +) anyerror!void { + + // create v8 vm + const vm = jsruntime.VM.init(); + defer vm.deinit(); + + // browser + var browser: Browser = undefined; + try Browser.init(&browser, alloc, loop, vm); + defer browser.deinit(); + + // create buffers + var read_buf: [BufReadSize]u8 = undefined; + var msg_buf = try MsgBuffer.init(loop.alloc, BufReadSize * 256); // 256KB + defer msg_buf.deinit(loop.alloc); + + // create I/O completions + var conn_completion: Completion = undefined; + var timeout_completion: Completion = undefined; + + // create I/O contexts and callbacks + // for accepting connections and receving messages + var ctx = Ctx{ + .loop = loop, + .browser = &browser, + .sessionNew = true, + .read_buf = &read_buf, + .msg_buf = &msg_buf, + .accept_socket = server_socket, + .timeout = timeout, + .conn_completion = &conn_completion, + .timeout_completion = &timeout_completion, + }; + try browser.session.initInspector( + &ctx, + Ctx.onInspectorResp, + Ctx.onInspectorNotif, + ); + + // accepting connection asynchronously on internal server + log.info("accepting new conn...", .{}); + loop.io.accept(*Ctx, &ctx, Ctx.acceptCbk, ctx.conn_completion, ctx.accept_socket); + + // infinite loop on I/O events, either: + // - cmd from incoming connection on server socket + // - JS callbacks events from scripts + while (true) { + try loop.io.tick(); + if (loop.cbk_error) { + log.err("JS error", .{}); + // if (try try_catch.exception(alloc, js_env.*)) |msg| { + // std.debug.print("\n\rUncaught {s}\n\r", .{msg}); + // alloc.free(msg); + // } + // loop.cbk_error = false; + } + if (ctx.err) |err| { + if (err != error.NoError) log.err("Server error: {any}", .{err}); + break; + } + } +} diff --git a/src/wpt/run.zig b/src/wpt/run.zig index 6a503e66..e2b58470 100644 --- a/src/wpt/run.zig +++ b/src/wpt/run.zig @@ -56,7 +56,8 @@ pub fn run(arena: *std.heap.ArenaAllocator, comptime dir: []const u8, f: []const var cli = Client{ .allocator = alloc, .loop = &loop }; defer cli.deinit(); - var js_env = try Env.init(alloc, &loop, UserContext{ + var js_env: Env = undefined; + Env.init(&js_env, alloc, &loop, UserContext{ .document = html_doc, .httpClient = &cli, }); diff --git a/vendor/zig-js-runtime b/vendor/zig-js-runtime index f2a6e94a..31d188d4 160000 --- a/vendor/zig-js-runtime +++ b/vendor/zig-js-runtime @@ -1 +1 @@ -Subproject commit f2a6e94a18488cd2d5ae296b74ce70ba4af0afbf +Subproject commit 31d188d4fbfb0da38cd448b9a9d1ca7720cd340d