From 4ff55a8cff45b67f8f2ad681cf968fdf8160996a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 22 Apr 2026 12:47:44 +0800 Subject: [PATCH 01/43] More Worker APIs Enable URL, AbortController and AbortSignal for Workers. Had a large impact because Event.initTrusted was changed from taking a *Frame to taking a *Session. Also, make WGS and Frame have a matching `dispatch` method so that it's easier to dispatch events against an executor (inline else =>). Similarly, added dupeString directly to executor to make migrations easier. --- src/browser/EventManager.zig | 4 +- src/browser/Frame.zig | 24 +++++++++--- src/browser/ScriptManager.zig | 2 +- src/browser/actions.zig | 6 +-- src/browser/js/Execution.zig | 10 +++++ src/browser/js/bridge.zig | 6 +-- src/browser/tests/worker/api-worker.js | 28 ++++++++++++++ src/browser/tests/worker/worker.html | 11 ++++++ src/browser/webapi/AbortController.zig | 13 ++++--- src/browser/webapi/AbortSignal.zig | 46 ++++++++++++----------- src/browser/webapi/Event.zig | 6 +-- src/browser/webapi/EventTarget.zig | 8 ++-- src/browser/webapi/URL.zig | 33 +++++++++------- src/browser/webapi/Window.zig | 4 +- src/browser/webapi/WorkerGlobalScope.zig | 32 ++++++++++++---- src/browser/webapi/css/FontFaceSet.zig | 4 +- src/browser/webapi/net/WebSocket.zig | 4 +- src/browser/webapi/net/XMLHttpRequest.zig | 2 +- 18 files changed, 167 insertions(+), 76 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 1b4231fb..994550e9 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -124,7 +124,7 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co // property is just a shortcut for calling addEventListener, but they are distinct. // An event set via property cannot be removed by removeEventListener. If you // set both the property and add a listener, they both execute. -const DispatchDirectOptions = EventManagerBase.DispatchDirectOptions; +pub const DispatchDirectOptions = EventManagerBase.DispatchDirectOptions; // Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with // property handlers. No propagation - just calls the handler and registered listeners. @@ -617,7 +617,7 @@ const ActivationState = struct { const event = try Event.initTrusted(comptime .wrap(typ), .{ .bubbles = true, .cancelable = false, - }, frame); + }, frame._session); const target = input.asElement().asEventTarget(); try frame._event_manager.dispatch(target, event); diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index b0f9e07a..8f1b120b 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -35,6 +35,7 @@ const URL = @import("URL.zig"); const Blob = @import("webapi/Blob.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); +const EventTarget = @import("webapi/EventTarget.zig"); const CData = @import("webapi/CData.zig"); const Element = @import("webapi/Element.zig"); const HtmlElement = @import("webapi/element/Html.zig"); @@ -806,7 +807,7 @@ pub fn documentIsLoaded(self: *Frame) void { } pub fn _documentIsLoaded(self: *Frame) !void { - const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self); + const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self._session); try self._event_manager.dispatch( self.document.asEventTarget(), event, @@ -833,7 +834,7 @@ pub fn iframeCompletedLoading(self: *Frame, iframe: *IFrame) void { defer entered.exit(); blk: { - const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| { + const event = Event.initTrusted(comptime .wrap("load"), .{}, self._session) catch |err| { log.err(.frame, "iframe event init", .{ .err = err, .url = iframe._src }); break :blk; }; @@ -887,7 +888,7 @@ fn _documentIsComplete(self: *Frame) !void { // Dispatch window.load event. const window_target = self.window.asEventTarget(); if (self._event_manager.hasDirectListeners(window_target, "load", self.window._on_load)) { - const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); + const event = try Event.initTrusted(comptime .wrap("load"), .{}, self._session); // This event is weird, it's dispatched directly on the window, but // with the document as the target. event._target = self.document.asEventTarget(); @@ -1467,7 +1468,7 @@ pub fn dispatchLoad(self: *Frame) !void { for (to_process.items) |html_element| { if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) { - const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); + const event = try Event.initTrusted(comptime .wrap("load"), .{}, self._session); try self._event_manager.dispatch(html_element.asEventTarget(), event); } } @@ -1578,7 +1579,7 @@ pub fn deliverSlotchangeEvents(self: *Frame) void { self._slots_pending_slotchange.clearRetainingCapacity(); for (slots) |slot| { - const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| { + const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self._session) catch |err| { log.err(.frame, "deliverSlotchange.init", .{ .err = err, .type = self._type, .url = self.url }); continue; }; @@ -2604,6 +2605,19 @@ pub fn dupeString(self: *Frame, value: []const u8) ![]const u8 { return self.arena.dupe(u8, value); } +// Direct (non-propagating) dispatch of an event. Mirrors WorkerGlobalScope.dispatch +// so worker-compatible APIs can uniformly call `global.dispatch(...)` across both +// Frame and Worker contexts. +pub fn dispatch( + self: *Frame, + target: *EventTarget, + event: *Event, + handler: anytype, + comptime opts: EventManager.DispatchDirectOptions, +) !void { + return self._event_manager.dispatchDirect(target, event, handler, opts); +} + pub fn dupeSSO(self: *Frame, value: []const u8) !String { return String.init(self.arena, value, .{ .dupe = true }); } diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 24fd7642..327ce69b 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -949,7 +949,7 @@ pub const Script = struct { fn executeCallback(self: *const Script, typ: String, frame: *Frame) void { const Event = @import("webapi/Event.zig"); - const event = Event.initTrusted(typ, .{}, frame) catch |err| { + const event = Event.initTrusted(typ, .{}, frame._session) catch |err| { log.warn(.js, "script internal callback", .{ .url = self.url, .type = typ, diff --git a/src/browser/actions.zig b/src/browser/actions.zig index cca8b9a0..d09cf57b 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -28,12 +28,12 @@ const Session = @import("Session.zig"); const Selector = @import("webapi/selector/Selector.zig"); fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void { - const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame); + const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame._session); frame._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { lp.log.err(.app, "dispatch input event failed", .{ .err = err }); }; - const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, frame); + const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, frame._session); frame._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { lp.log.err(.app, "dispatch change event failed", .{ .err = err }); }; @@ -196,7 +196,7 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, frame: *Frame) !void { }; } - const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, frame); + const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, frame._session); frame._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { lp.log.err(.app, "dispatch scroll event failed", .{ .err = err }); }; diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig index 6acfe455..e752e904 100644 --- a/src/browser/js/Execution.zig +++ b/src/browser/js/Execution.zig @@ -26,10 +26,13 @@ //! whether it's a Page context or a Worker context. const std = @import("std"); +const lp = @import("lightpanda"); + const Context = @import("Context.zig"); const Scheduler = @import("Scheduler.zig"); const Factory = @import("../Factory.zig"); +const String = lp.String; const Allocator = std.mem.Allocator; const Execution = @This(); @@ -53,3 +56,10 @@ charset: *[]const u8, pub fn base(self: *const Execution) [:0]const u8 { return self.context.global.base(); } + +pub fn dupeString(self: *const Execution, value: []const u8) ![]const u8 { + if (String.intern(value)) |v| { + return v; + } + return self.arena.dupe(u8, value); +} diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 32ac0de2..e61818c7 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -933,10 +933,10 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/streams/WritableStreamDefaultController.zig"), @import("../webapi/encoding/TextEncoderStream.zig"), @import("../webapi/encoding/TextDecoderStream.zig"), - // @import("../webapi/URL.zig"), + @import("../webapi/AbortSignal.zig"), + @import("../webapi/AbortController.zig"), + @import("../webapi/URL.zig"), // @import("../webapi/Performance.zig"), - // @import("../webapi/AbortSignal.zig"), - // @import("../webapi/AbortController.zig"), }); // Master list of ALL JS APIs across all contexts. diff --git a/src/browser/tests/worker/api-worker.js b/src/browser/tests/worker/api-worker.js index 2c01d8fd..edfea446 100644 --- a/src/browser/tests/worker/api-worker.js +++ b/src/browser/tests/worker/api-worker.js @@ -29,6 +29,27 @@ const response_body_text = await response.text(); const response_clone_text = await response.clone().text(); + // AbortController + AbortSignal dispatch (exercises the inline-else dispatch path) + const controller = new AbortController(); + const ac_aborted_before = controller.signal.aborted; + let ac_listener_fired = false; + controller.signal.addEventListener('abort', () => { ac_listener_fired = true; }); + controller.abort('cancelled'); + const ac_aborted_after = controller.signal.aborted; + const ac_reason = String(controller.signal.reason); + + // Pre-aborted static constructor + throwIfAborted + const pre = AbortSignal.abort('already-gone'); + const pre_aborted = pre.aborted; + let pre_threw = false; + try { pre.throwIfAborted(); } catch (_) { pre_threw = true; } + + // URL.createObjectURL / revokeObjectURL from a worker + const blob = new Blob(['hello worker'], { type: 'text/plain' }); + const blob_url = URL.createObjectURL(blob); + const blob_url_is_blob = blob_url.startsWith('blob:'); + URL.revokeObjectURL(blob_url); + postMessage({ ok: true, results: { @@ -47,6 +68,13 @@ response_headers_content_type: response.headers.get('content-type'), response_body_text, response_clone_text, + ac_aborted_before, + ac_aborted_after, + ac_listener_fired, + ac_reason, + pre_aborted, + pre_threw, + blob_url_is_blob, }, }); } catch (e) { diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html index c0960231..1d912060 100644 --- a/src/browser/tests/worker/worker.html +++ b/src/browser/tests/worker/worker.html @@ -209,6 +209,17 @@ testing.expectEqual('text/plain', r.response_headers_content_type); testing.expectEqual('response body', r.response_body_text); testing.expectEqual('response body', r.response_clone_text); + + // AbortController / AbortSignal + testing.expectEqual(false, r.ac_aborted_before); + testing.expectEqual(true, r.ac_aborted_after); + testing.expectEqual(true, r.ac_listener_fired); + testing.expectEqual('cancelled', r.ac_reason); + testing.expectEqual(true, r.pre_aborted); + testing.expectEqual(true, r.pre_threw); + + // URL.createObjectURL / revokeObjectURL + testing.expectEqual(true, r.blob_url_is_blob); }); } diff --git a/src/browser/webapi/AbortController.zig b/src/browser/webapi/AbortController.zig index d4d0d07f..0c751497 100644 --- a/src/browser/webapi/AbortController.zig +++ b/src/browser/webapi/AbortController.zig @@ -19,16 +19,17 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Frame = @import("../Frame.zig"); const AbortSignal = @import("AbortSignal.zig"); +const Execution = js.Execution; + const AbortController = @This(); _signal: *AbortSignal, -pub fn init(frame: *Frame) !*AbortController { - const signal = try AbortSignal.init(frame); - return frame._factory.create(AbortController{ +pub fn init(exec: *const Execution) !*AbortController { + const signal = try AbortSignal.init(exec); + return exec._factory.create(AbortController{ ._signal = signal, }); } @@ -37,8 +38,8 @@ pub fn getSignal(self: *const AbortController) *AbortSignal { return self._signal; } -pub fn abort(self: *AbortController, reason_: ?js.Value.Global, frame: *Frame) !void { - try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, frame); +pub fn abort(self: *AbortController, reason_: ?js.Value.Global, exec: *const Execution) !void { + try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec); } pub const JsApi = struct { diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index f4e554fc..8e1241e0 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -20,12 +20,12 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); -const Frame = @import("../Frame.zig"); const Event = @import("Event.zig"); const EventTarget = @import("EventTarget.zig"); const log = lp.log; +const Execution = js.Execution; const AbortSignal = @This(); @@ -34,8 +34,8 @@ _aborted: bool = false, _reason: Reason = .undefined, _on_abort: ?js.Function.Global = null, -pub fn init(frame: *Frame) !*AbortSignal { - return frame._factory.eventTarget(AbortSignal{ +pub fn init(exec: *const Execution) !*AbortSignal { + return exec._factory.eventTarget(AbortSignal{ ._proto = undefined, }); } @@ -60,7 +60,7 @@ pub fn asEventTarget(self: *AbortSignal) *EventTarget { return self._proto; } -pub fn abort(self: *AbortSignal, reason_: ?Reason, frame: *Frame) !void { +pub fn abort(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void { if (self._aborted) { return; } @@ -71,36 +71,40 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, frame: *Frame) !void { if (reason_) |reason| { switch (reason) { .js_val => |js_val| self._reason = .{ .js_val = js_val }, - .string => |str| self._reason = .{ .string = try frame.dupeString(str) }, + .string => |str| self._reason = .{ .string = try exec.dupeString(str) }, .undefined => self._reason = reason, } } else { self._reason = .{ .string = "AbortError" }; } - // Dispatch abort event const target = self.asEventTarget(); - if (frame._event_manager.hasDirectListeners(target, "abort", self._on_abort)) { - const event = try Event.initTrusted(comptime .wrap("abort"), .{}, frame); - try frame._event_manager.dispatchDirect(target, event, self._on_abort, .{ .context = "abort signal" }); + const on_abort = self._on_abort; + switch (exec.context.global) { + inline else => |g| { + if (g._event_manager.hasDirectListeners(target, "abort", on_abort)) { + const event = try Event.initTrusted(comptime .wrap("abort"), .{}, g._session); + try g.dispatch(target, event, on_abort, .{ .context = "abort signal" }); + } + }, } } // Static method to create an already-aborted signal -pub fn createAborted(reason_: ?js.Value.Global, frame: *Frame) !*AbortSignal { - const signal = try init(frame); - try signal.abort(if (reason_) |r| .{ .js_val = r } else null, frame); +pub fn createAborted(reason_: ?js.Value.Global, exec: *const Execution) !*AbortSignal { + const signal = try init(exec); + try signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec); return signal; } -pub fn createTimeout(delay: u32, frame: *Frame) !*AbortSignal { - const callback = try frame.arena.create(TimeoutCallback); +pub fn createTimeout(delay: u32, exec: *const Execution) !*AbortSignal { + const callback = try exec.arena.create(TimeoutCallback); callback.* = .{ - .frame = frame, - .signal = try init(frame), + .exec = exec, + .signal = try init(exec), }; - try frame.js.scheduler.add(callback, TimeoutCallback.run, delay, .{ + try exec._scheduler.add(callback, TimeoutCallback.run, delay, .{ .name = "AbortSignal.timeout", }); @@ -111,8 +115,8 @@ const ThrowIfAborted = union(enum) { exception: js.Exception, undefined: void, }; -pub fn throwIfAborted(self: *const AbortSignal, frame: *Frame) !ThrowIfAborted { - const local = frame.js.local.?; +pub fn throwIfAborted(self: *const AbortSignal, exec: *const Execution) !ThrowIfAborted { + const local = exec.context.local.?; if (self._aborted) { const exception = switch (self._reason) { @@ -132,12 +136,12 @@ const Reason = union(enum) { }; const TimeoutCallback = struct { - frame: *Frame, + exec: *const Execution, signal: *AbortSignal, fn run(ctx: *anyopaque) !?u32 { const self: *TimeoutCallback = @ptrCast(@alignCast(ctx)); - self.signal.abort(.{ .string = "TimeoutError" }, self.frame) catch |err| { + self.signal.abort(.{ .string = "TimeoutError" }, self.exec) catch |err| { log.warn(.app, "abort signal timeout", .{ .err = err }); }; return null; diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index f8117e39..b3053b8e 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -96,9 +96,9 @@ pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*Event { return initWithTrusted(arena, str, opts_, false); } -pub fn initTrusted(typ: String, opts_: ?Options, frame: *Frame) !*Event { - const arena = try frame.getArena(.tiny, "Event.trusted"); - errdefer frame.releaseArena(arena); +pub fn initTrusted(typ: String, opts_: ?Options, session: *Session) !*Event { + const arena = try session.getArena(.tiny, "Event.trusted"); + errdefer session.releaseArena(arena); return initWithTrusted(arena, typ, opts_, true); } diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index f7effdd6..2b52c3ec 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -70,7 +70,7 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, exec: *js.Execution) !bo defer _ = event.releaseRef(frame._session); try frame._event_manager.dispatch(self, event); }, - .worker => |wgs| try wgs.dispatch(self, event, null), + .worker => |wgs| try wgs.dispatch(self, event, null, .{}), } return !event._cancelable or !event._prevent_default; } @@ -101,8 +101,7 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi }; switch (exec.context.global) { - .frame => |frame| _ = try frame._event_manager.register(self, typ, em_callback, options), - .worker => |wgs| _ = try wgs._event_manager.register(self, typ, em_callback, options), + inline else => |g| _ = try g._event_manager.register(self, typ, em_callback, options), } } @@ -138,8 +137,7 @@ pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?Even }; switch (exec.context.global) { - .frame => |frame| frame._event_manager.remove(self, typ, em_callback, use_capture), - .worker => |wgs| wgs._event_manager.remove(self, typ, em_callback, use_capture), + inline else => |g| g._event_manager.remove(self, typ, em_callback, use_capture), } } diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index bb1f712e..76f12142 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -20,7 +20,6 @@ const std = @import("std"); const js = @import("../js/js.zig"); const U = @import("../URL.zig"); -const Frame = @import("../Frame.zig"); const URLSearchParams = @import("net/URLSearchParams.zig"); const Blob = @import("Blob.zig"); const Execution = js.Execution; @@ -248,28 +247,36 @@ pub fn canParse(url: []const u8, base_: ?[]const u8) bool { return U.isCompleteHTTPUrl(url); } -pub fn createObjectURL(blob: *Blob, frame: *Frame) ![]const u8 { +pub fn createObjectURL(blob: *Blob, exec: *const Execution) ![]const u8 { var uuid_buf: [36]u8 = undefined; @import("../../id.zig").uuidv4(&uuid_buf); - const blob_url = try std.fmt.allocPrint( - frame.arena, - "blob:{s}/{s}", - .{ frame.origin orelse "null", uuid_buf }, - ); - try frame._blob_urls.put(frame.arena, blob_url, blob); - blob.acquireRef(); - return blob_url; + switch (exec.context.global) { + inline else => |g| { + const blob_url = try std.fmt.allocPrint( + g.arena, + "blob:{s}/{s}", + .{ g.origin orelse "null", uuid_buf }, + ); + try g._blob_urls.put(g.arena, blob_url, blob); + blob.acquireRef(); + return blob_url; + }, + } } -pub fn revokeObjectURL(url: []const u8, frame: *Frame) void { +pub fn revokeObjectURL(url: []const u8, exec: *const Execution) void { // Per spec: silently ignore non-blob URLs if (!std.mem.startsWith(u8, url, "blob:")) { return; } - if (frame._blob_urls.fetchRemove(url)) |entry| { - entry.value.releaseRef(frame._session); + switch (exec.context.global) { + inline else => |g| { + if (g._blob_urls.fetchRemove(url)) |entry| { + entry.value.releaseRef(g._session); + } + }, } } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ea53a242..96df3ea7 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -538,7 +538,7 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void return null; } - const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, p); + const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, p._session); try p._event_manager.dispatch(p.document.asEventTarget(), event); pos.state = .end; @@ -565,7 +565,7 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void .end => {}, .done => return null, } - const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, p); + const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, p._session); try p._event_manager.dispatch(p.document.asEventTarget(), event); pos.state = .done; diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 9aa0f3a3..e6c35b05 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -28,6 +28,7 @@ const Factory = @import("../Factory.zig"); const Session = @import("../Session.zig"); const EventManagerBase = @import("../EventManagerBase.zig"); +const Blob = @import("Blob.zig"); const Worker = @import("Worker.zig"); const Crypto = @import("Crypto.zig"); const Console = @import("Console.zig"); @@ -52,11 +53,16 @@ _identity: JS.Identity = .{}, arena: Allocator, call_arena: Allocator, url: [:0]const u8, +// Same-origin constraint: a worker's origin is inherited from its parent frame. +origin: ?[]const u8 = null, buf: [1024]u8 = undefined, // same size as frame.buf // Document charset (matches Page.charset). Workers default to UTF-8. charset: []const u8 = "UTF-8", js: *JS.Context, +// Blob URL registry for URL.createObjectURL/revokeObjectURL. +_blob_urls: std.StringHashMapUnmanaged(*Blob) = .{}, + // Reference back to the Worker object (for postMessage to frame) _worker: *Worker, @@ -76,7 +82,8 @@ _on_messageerror: ?JS.Function.Global = null, pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { const arena = worker._arena; - const session = worker._frame._session; + const parent = worker._frame; + const session = parent._session; const factory = &session.factory; const call_arena = try session.getArena(.small, "WorkerGlobalScope.call_arena"); @@ -85,6 +92,7 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { const self = try factory.eventTargetWithAllocator(arena, WorkerGlobalScope{ .url = url, .arena = arena, + .origin = parent.origin, .js = undefined, .call_arena = call_arena, ._session = session, @@ -108,6 +116,10 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { pub fn deinit(self: *WorkerGlobalScope) void { self._identity.deinit(); const session = self._session; + var it = self._blob_urls.valueIterator(); + while (it.next()) |blob| { + blob.*.releaseRef(session); + } session.browser.env.destroyContext(self.js); session.releaseArena(self.call_arena); } @@ -123,7 +135,13 @@ pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget { const Event = @import("Event.zig"); // Dispatch an event to listeners on the given target within this worker context. -pub fn dispatch(self: *WorkerGlobalScope, target: *EventTarget, event: *Event, handler: anytype) !void { +pub fn dispatch( + self: *WorkerGlobalScope, + target: *EventTarget, + event: *Event, + handler: anytype, + comptime opts: EventManagerBase.DispatchDirectOptions, +) !void { try self._event_manager.dispatchDirect( self.call_arena, self.js, @@ -131,7 +149,7 @@ pub fn dispatch(self: *WorkerGlobalScope, target: *EventTarget, event: *Event, h event, handler, self._session, - .{}, + opts, ); } @@ -265,7 +283,7 @@ pub fn unhandledPromiseRejection(self: *WorkerGlobalScope, no_handler: bool, rej .reason = if (rejection.reason()) |r| try r.temp() else null, .promise = try rejection.promise().temp(), }, self._session)).asEvent(); - try self.dispatch(target, event, attribute_callback); + try self.dispatch(target, event, attribute_callback, .{}); } } @@ -311,7 +329,7 @@ pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { event._prevent_default = prevent_default; // Pass null as handler: onerror was already called above with 5 args. // We still dispatch so that addEventListener('error', ...) listeners fire. - try self.dispatch(self.asEventTarget(), event, null); + try self.dispatch(self.asEventTarget(), event, null, .{}); if (comptime builtin.is_test == false) { if (!event._prevent_default) { @@ -375,7 +393,7 @@ const ReceiveMessageCallback = struct { .bubbles = false, .cancelable = false, }, worker_scope._session)).asEvent(); - try worker_scope.dispatch(target, event, on_messageerror); + try worker_scope.dispatch(target, event, on_messageerror, .{}); return null; } @@ -392,7 +410,7 @@ const ReceiveMessageCallback = struct { .bubbles = false, .cancelable = false, }, worker_scope._session)).asEvent(); - try worker_scope.dispatch(target, event, on_message); + try worker_scope.dispatch(target, event, on_message, .{}); return null; } }; diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig index e5009c85..6d4695da 100644 --- a/src/browser/webapi/css/FontFaceSet.zig +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -80,13 +80,13 @@ pub fn load(self: *FontFaceSet, font: []const u8, frame: *Frame) !js.Promise { // Dispatch loading event const target = self.asEventTarget(); if (frame._event_manager.hasDirectListeners(target, "loading", null)) { - const event = try Event.initTrusted(comptime .wrap("loading"), .{}, frame); + const event = try Event.initTrusted(comptime .wrap("loading"), .{}, frame._session); try frame._event_manager.dispatchDirect(target, event, null, .{ .context = "load font face set" }); } // Dispatch loadingdone event if (frame._event_manager.hasDirectListeners(target, "loadingdone", null)) { - const event = try Event.initTrusted(comptime .wrap("loadingdone"), .{}, frame); + const event = try Event.initTrusted(comptime .wrap("loadingdone"), .{}, frame._session); try frame._event_manager.dispatchDirect(target, event, null, .{ .context = "load font face set" }); } diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 74bd668b..7338234f 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -452,7 +452,7 @@ fn dispatchOpenEvent(self: *WebSocket) !void { const target = self.asEventTarget(); if (frame._event_manager.hasDirectListeners(target, "open", self._on_open)) { - const event = try Event.initTrusted(comptime .wrap("open"), .{}, frame); + const event = try Event.initTrusted(comptime .wrap("open"), .{}, frame._session); try frame._event_manager.dispatchDirect(target, event, self._on_open, .{ .context = "WebSocket open" }); } } @@ -487,7 +487,7 @@ fn dispatchErrorEvent(self: *WebSocket) !void { const target = self.asEventTarget(); if (frame._event_manager.hasDirectListeners(target, "error", self._on_error)) { - const event = try Event.initTrusted(comptime .wrap("error"), .{}, frame); + const event = try Event.initTrusted(comptime .wrap("error"), .{}, frame._session); try frame._event_manager.dispatchDirect(target, event, self._on_error, .{ .context = "WebSocket error" }); } } diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 660be153..10dc62f4 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -588,7 +588,7 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, frame: *Frame) !void { const target = self.asEventTarget(); if (frame._event_manager.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) { - const event = try Event.initTrusted(.wrap("readystatechange"), .{}, frame); + const event = try Event.initTrusted(.wrap("readystatechange"), .{}, frame._session); try frame._event_manager.dispatchDirect(target, event, self._on_ready_state_change, .{ .context = "XHR state change" }); } } From ed512d3203e00029b72129081ecddf73f5721e9f Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 7 Apr 2026 16:38:15 +0300 Subject: [PATCH 02/43] introduce `cli_parser` Generalized way to define command line interfaces. --- src/cli_parser.zig | 340 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 src/cli_parser.zig diff --git a/src/cli_parser.zig b/src/cli_parser.zig new file mode 100644 index 00000000..0877f081 --- /dev/null +++ b/src/cli_parser.zig @@ -0,0 +1,340 @@ +// Copyright (C) 2023-2026 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 Allocator = std.mem.Allocator; + +/// Creates a new CLI parser from given commands recipe. +pub fn Commands(comptime commands: anytype) type { + return struct { + const Self = @This(); + + /// Enum type for provided commands. + pub const Enum = blk: { + var enum_fields: [commands.len]std.builtin.Type.EnumField = undefined; + for (commands, 0..) |command, i| { + enum_fields[i] = .{ .name = command.name, .value = i }; + } + + break :blk @Type(.{ + .@"enum" = .{ + .decls = &.{}, + .fields = &enum_fields, + .is_exhaustive = true, + .tag_type = std.math.IntFittingRange(0, commands.len), + }, + }); + }; + + /// Union type for provided commands. + pub const Union = blk: { + var union_fields: [commands.len]std.builtin.Type.UnionField = undefined; + // Populate both `enum_fields` and `union_fields`. + for (commands, 0..) |command, i| { + const options = command.options; + + // Whether this command has `positional` argument. + const has_positional = @hasField(@TypeOf(command), "positional"); + const fields_len = if (has_positional) options.len + 1 else options.len; + + var struct_fields: [fields_len]std.builtin.Type.StructField = undefined; + for (options, 0..) |option, j| { + const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple; + const T, const default = type_and_default: { + if (is_multiple) { + // We currently don't allow default values for lists. + if (@hasField(@TypeOf(option), "default")) unreachable; + // If `multiple` provided, prefer `ArrayList`. + const T = std.ArrayList(option.type); + break :type_and_default .{ T, &@as(T, .{}) }; + } + + const T = option.type; + break :type_and_default .{ T, &@as(T, option.default) }; + }; + + struct_fields[j] = .{ + .name = option.name, + .type = T, + .default_value_ptr = default, + .is_comptime = false, + .alignment = @alignOf(T), + }; + } + + // Add a field for `positional`. + if (has_positional) { + const positional = command.positional; + const T = positional.type; + struct_fields[fields_len - 1] = .{ + .name = positional.name, + .type = T, + .default_value_ptr = &@as(T, null), + .is_comptime = false, + .alignment = @alignOf(T), + }; + } + + const T = @Type(.{ + .@"struct" = .{ + .decls = &.{}, + .fields = &struct_fields, + .is_tuple = false, + .layout = .auto, + }, + }); + + union_fields[i] = .{ .name = command.name, .type = T, .alignment = @alignOf(T) }; + } + + break :blk @Type(.{ + .@"union" = .{ + .decls = &.{}, + .fields = &union_fields, + .layout = .auto, + .tag_type = Enum, + }, + }); + }; + + pub fn parse(allocator: Allocator) !Union { + var args = try std.process.argsWithAllocator(allocator); + defer args.deinit(); + + // Skip program name. + const exec_name = std.fs.path.basename(args.next().?); + _ = exec_name; + + const cmd_str: []const u8 = args.next().?; + + inline for (commands) |command| { + if (std.mem.eql(u8, cmd_str, command.name)) { + return parseCommand(allocator, command, &args); + } + } + + // TODO: Unknown command. + @panic("unknown command"); + } + + /// Parses the command with its options. + fn parseCommand( + allocator: Allocator, + command: anytype, + args: *std.process.ArgIterator, + ) !Union { + const Command = @FieldType(Union, command.name); + var c = Command{}; + + const options = command.options; + iter_args: while (args.next()) |option_name| { + inline for (options) |option| { + // We allow both `--my-option` and `--my_option` variants; + // assuming given `option` struct prefer snake_case for name. + const kebab_cased = comptime blk: { + var output: [option.name.len]u8 = undefined; + @memcpy(&output, option.name); + std.mem.replaceScalar(u8, &output, '_', '-'); + break :blk output; + }; + + // Match an option. + if (std.mem.eql(u8, option_name, "--" ++ option.name) or + std.mem.eql(u8, option_name, "--" ++ kebab_cased)) + { + const T = option.type; + const option_info = blk: { + const info = @typeInfo(T); + // If wrapped in optional, prefer the child type. + if (info == .optional) break :blk @typeInfo(info.optional.child); + break :blk info; + }; + + // TODO: Support multiples. + const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple; + + switch (option_info) { + .int => |int| { + const Int = std.meta.Int(int.signedness, int.bits); + // TODO: Return correct errors. + const v = try std.fmt.parseInt(Int, args.next().?, 10); + + if (is_multiple) { + // Push to ArrayList. + try @field(c, option.name).append(allocator, v); + } else { + @field(c, option.name) = v; + } + }, + .pointer => |pointer| { + const not_u8_slice = pointer.child != u8 or pointer.size != .slice; + if (not_u8_slice) { + @compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported"); + } + + // TODO: Return error. + const str = args.next().?; + + const v = blk: { + // DupeZ branch. + if (comptime pointer.sentinel()) |sentinel| { + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1); + @memcpy(buf[0..str.len], str); + buf[str.len] = sentinel; + break :blk buf[0..str.len :sentinel]; + } + + // Dupe branch. + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len); + @memcpy(buf, str); + break :blk buf; + }; + + if (is_multiple) { + try @field(c, option.name).append(allocator, v); + } else { + @field(c, option.name) = v; + } + }, + .@"struct" => |_struct| { + // Don't support multiple for structs for now. + if (is_multiple) { + @compileError("multiple option is not supported for structs"); + } + + const not_packed = _struct.layout != .@"packed"; + if (not_packed) { + @compileError("only packed structs are allowed"); + } + + // TODO: Return error. + const str = args.next().?; + + if (std.mem.eql(u8, str, "all")) { + // "all" sets all the fields of packed struct. + const Int = _struct.backing_integer.?; + @field(c, option.name) = @bitCast(@as(Int, std.math.maxInt(Int))); + } else { + // Parse given args. + var it = std.mem.splitScalar(u8, str, ','); + outer: while (it.next()) |part| { + const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); + + inline for (_struct.fields) |f| { + std.debug.assert(f.type == bool); + + if (std.mem.eql(u8, trimmed, @as([]const u8, f.name))) { + @field(@field(c, option.name), f.name) = true; + continue :outer; + } + } + } + } + }, + .@"enum" => { + if (is_multiple) { + @compileError("multiple option is not supported for enums"); + } + + const E = switch (@typeInfo(T)) { + .optional => |optional| optional.child, + inline else => T, + }; + + // This type only, we peek ahead to check if there's a following arg. + // If there isn't, the default is set already. + var peek_args = args.*; + if (peek_args.next()) |next_arg| { + const v = std.meta.stringToEnum(E, next_arg) orelse { + return error.UnknownArgument; + }; + // Discard. + _ = args.next(); + + @field(c, option.name) = v; + } + }, + .bool => { + if (is_multiple) { + @compileError("multiple option is not supported for booleans"); + } + + @field(c, option.name) = true; + }, + + else => {}, + } + + continue :iter_args; + } + } + + // An option we don't know of. + if (std.mem.startsWith(u8, option_name, "--")) { + return error.UnkownOption; + } + + // Parse positional arg if provided; can be given out of order: + // + // lightpanda fetch --wait-ms 2_000 "https://lightpanda.io" --dump "html" + // ---------------------------------^ + if (comptime @hasField(@TypeOf(command), "positional")) { + const positional = command.positional; + + // Already given one. + if (@field(c, positional.name) != null) { + return error.TooManyPositionalArgs; + } + + // The positional must be an optional type. + const info = @typeInfo(@typeInfo(positional.type).optional.child); + + const str = @as([]const u8, option_name); + switch (info) { + .pointer => |pointer| { + const not_u8_slice = pointer.child != u8 or pointer.size != .slice; + if (not_u8_slice) { + @compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported"); + } + + const v = blk: { + // DupeZ branch. + if (comptime pointer.sentinel()) |sentinel| { + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1); + @memcpy(buf[0..str.len], str); + buf[str.len] = sentinel; + break :blk buf[0..str.len :sentinel]; + } + + // Dupe branch. + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len); + @memcpy(buf, str); + break :blk buf; + }; + + @field(c, positional.name) = v; + }, + inline else => @compileError("not supported"), + } + } + } + + return @unionInit(Union, command.name, c); + } + }; +} From ce62e1c631e5c4737288ecfa824496b3bc5c94c8 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 8 Apr 2026 17:39:37 +0300 Subject: [PATCH 03/43] `dump.Opts.Strip`: struct -> packed struct(u3) `cli.zig` only understands packed structs currently. --- src/browser/dump.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 84bcc8a6..3a8e1bf7 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -30,7 +30,7 @@ pub const Opts = struct { strip: Opts.Strip = .{}, shadow: Opts.Shadow = .rendered, - pub const Strip = struct { + pub const Strip = packed struct(u3) { js: bool = false, ui: bool = false, css: bool = false, From 9bf85214e3d6fd2a7fa97decf56cf6959ba67daf Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 8 Apr 2026 17:40:44 +0300 Subject: [PATCH 04/43] `cli`: add support for command aliases and shared options --- src/{cli_parser.zig => cli.zig} | 126 ++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 53 deletions(-) rename src/{cli_parser.zig => cli.zig} (79%) diff --git a/src/cli_parser.zig b/src/cli.zig similarity index 79% rename from src/cli_parser.zig rename to src/cli.zig index 0877f081..358d14f7 100644 --- a/src/cli_parser.zig +++ b/src/cli.zig @@ -19,8 +19,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -/// Creates a new CLI parser from given commands recipe. -pub fn Commands(comptime commands: anytype) type { +pub fn Builder(comptime commands: anytype) type { return struct { const Self = @This(); @@ -41,59 +40,64 @@ pub fn Commands(comptime commands: anytype) type { }); }; + /// Creates an array of `StructField` out of given options. + fn optionsToStructFields(comptime options: anytype) [options.len]std.builtin.Type.StructField { + var fields: [options.len]std.builtin.Type.StructField = undefined; + + inline for (options, 0..) |option, j| { + const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple; + const T, const default = type_and_default: { + if (is_multiple) { + // We currently don't allow default values for lists. + if (@hasField(@TypeOf(option), "default")) unreachable; + // If `multiple` provided, prefer `ArrayList`. + const T = std.ArrayList(option.type); + break :type_and_default .{ T, &@as(T, .{}) }; + } + + const T = option.type; + break :type_and_default .{ T, &@as(T, option.default) }; + }; + + fields[j] = .{ + .name = option.name, + .type = T, + .default_value_ptr = @ptrCast(default), + .is_comptime = false, + .alignment = @alignOf(T), + }; + } + + return fields; + } + /// Union type for provided commands. pub const Union = blk: { var union_fields: [commands.len]std.builtin.Type.UnionField = undefined; - // Populate both `enum_fields` and `union_fields`. for (commands, 0..) |command, i| { + const Command = @TypeOf(command); const options = command.options; - // Whether this command has `positional` argument. - const has_positional = @hasField(@TypeOf(command), "positional"); - const fields_len = if (has_positional) options.len + 1 else options.len; - - var struct_fields: [fields_len]std.builtin.Type.StructField = undefined; - for (options, 0..) |option, j| { - const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple; - const T, const default = type_and_default: { - if (is_multiple) { - // We currently don't allow default values for lists. - if (@hasField(@TypeOf(option), "default")) unreachable; - // If `multiple` provided, prefer `ArrayList`. - const T = std.ArrayList(option.type); - break :type_and_default .{ T, &@as(T, .{}) }; - } - - const T = option.type; - break :type_and_default .{ T, &@as(T, option.default) }; - }; - - struct_fields[j] = .{ - .name = option.name, - .type = T, - .default_value_ptr = default, - .is_comptime = false, - .alignment = @alignOf(T), - }; - } - - // Add a field for `positional`. - if (has_positional) { - const positional = command.positional; - const T = positional.type; - struct_fields[fields_len - 1] = .{ - .name = positional.name, - .type = T, - .default_value_ptr = &@as(T, null), - .is_comptime = false, - .alignment = @alignOf(T), - }; - } - const T = @Type(.{ .@"struct" = .{ .decls = &.{}, - .fields = &struct_fields, + .fields = &(optionsToStructFields(options) ++ + (if (@hasField(Command, "shared_options")) + optionsToStructFields(command.shared_options) + else + .{}) ++ + (if (@hasField(Command, "positional")) + [1]std.builtin.Type.StructField{ + .{ + .name = command.positional.name, + .type = command.positional.type, + .default_value_ptr = @ptrCast(&@as(command.positional.type, null)), + .is_comptime = false, + .alignment = @alignOf(command.positional.type), + }, + } + else + .{})), .is_tuple = false, .layout = .auto, }, @@ -112,19 +116,29 @@ pub fn Commands(comptime commands: anytype) type { }); }; - pub fn parse(allocator: Allocator) !Union { + /// Parses executable name, command and options via single call. + pub fn parse(allocator: Allocator) !struct { []const u8, Union } { var args = try std.process.argsWithAllocator(allocator); defer args.deinit(); - // Skip program name. const exec_name = std.fs.path.basename(args.next().?); - _ = exec_name; + // TODO: Return error. const cmd_str: []const u8 = args.next().?; - inline for (commands) |command| { - if (std.mem.eql(u8, cmd_str, command.name)) { - return parseCommand(allocator, command, &args); + // Command name together with it's aliases. + const with_aliases = blk: { + if (@hasField(@TypeOf(command), "aliases")) { + break :blk command.aliases ++ .{command.name}; + } + + break :blk .{command.name}; + }; + + inline for (with_aliases) |name| { + if (std.mem.eql(u8, cmd_str, name)) { + return .{ exec_name, try parseCommand(allocator, command, &args) }; + } } } @@ -141,7 +155,13 @@ pub fn Commands(comptime commands: anytype) type { const Command = @FieldType(Union, command.name); var c = Command{}; - const options = command.options; + const options = blk: { + if (@hasField(@TypeOf(command), "shared_options")) { + break :blk command.options ++ command.shared_options; + } + + break :blk command.options; + }; iter_args: while (args.next()) |option_name| { inline for (options) |option| { // We allow both `--my-option` and `--my_option` variants; From 81b89e67b72e58b6eeca84ada43b5c0b0b0b2134 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 8 Apr 2026 17:41:42 +0300 Subject: [PATCH 05/43] `Config`: adapt to new CLI builder --- src/Config.zig | 939 ++++++------------------------------------------- 1 file changed, 106 insertions(+), 833 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index a49d41e4..8fa53c4a 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const builtin = @import("builtin"); +const cli = @import("cli.zig"); const dump = @import("browser/dump.zig"); const mcp = @import("mcp.zig"); @@ -27,16 +28,6 @@ const Storage = @import("storage/Storage.zig"); const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config; const log = lp.log; -const Allocator = std.mem.Allocator; - -pub const RunMode = enum { - help, - fetch, - serve, - version, - mcp, -}; - pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; // max message size @@ -44,12 +35,79 @@ pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; // +140 for the max control packet that might be interleaved in a message pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140; +const Config = @This(); + +/// Common CLI args. +const CommonOptions = .{ + .{ .name = "obey_robots", .type = bool, .default = false }, + .{ .name = "proxy_bearer_token", .type = ?[:0]const u8, .default = null }, + .{ .name = "http_proxy", .type = ?[:0]const u8, .default = null }, + .{ .name = "http_max_concurrent", .type = ?u8, .default = null }, + .{ .name = "http_max_host_open", .type = ?u8, .default = null }, + .{ .name = "http_timeout", .type = ?u31, .default = null }, + .{ .name = "http_connect_timeout", .type = ?u31, .default = null }, + .{ .name = "http_max_response_size", .type = ?usize, .default = null }, + .{ .name = "ws_max_concurrent", .type = ?u8, .default = null }, + .{ .name = "insecure_disable_tls_host_verification", .type = bool, .default = false }, + .{ .name = "log_level", .type = ?log.Level, .default = null }, + .{ .name = "log_format", .type = ?log.Format, .default = null }, + // TODO: log_filter_scopes (?[]log.Scope) — parser doesn't yet support `multiple` for enums. + .{ .name = "user_agent_suffix", .type = ?[]const u8, .default = null }, + .{ .name = "http_cache_dir", .type = ?[]const u8, .default = null }, + .{ .name = "web_bot_auth_key_file", .type = ?[]const u8, .default = null }, + .{ .name = "web_bot_auth_keyid", .type = ?[]const u8, .default = null }, + .{ .name = "web_bot_auth_domain", .type = ?[]const u8, .default = null }, +}; + +/// Definition for all the commands and its arguments. See @cli.zig for further. +const Commands = cli.Builder(.{ + .{ + .name = "serve", + .options = .{ + .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, + .{ .name = "port", .type = u16, .default = 9222 }, + .{ .name = "advertise_host", .type = ?[]const u8, .default = null }, + .{ .name = "timeout", .type = u31, .default = 10 }, + .{ .name = "cdp_max_connections", .type = u16, .default = 16 }, + .{ .name = "cdp_max_pending_connections", .type = u16, .default = 128 }, + }, + .shared_options = CommonOptions, + }, + .{ + .name = "fetch", + .aliases = .{ "f", "get" }, + // Fetch only; this argument can be given anywhere. + .positional = .{ .name = "url", .type = ?[:0]const u8 }, + .options = .{ + .{ .name = "dump", .type = ?DumpFormat, .default = null }, + .{ .name = "with_base", .type = bool, .default = false }, + .{ .name = "with_frames", .type = bool, .default = false }, + .{ .name = "strip", .type = dump.Opts.Strip, .default = dump.Opts.Strip{} }, + .{ .name = "wait_ms", .type = u32, .default = 5_000 }, + .{ .name = "wait_until", .type = ?WaitUntil, .default = null }, + .{ .name = "wait_script", .type = ?[:0]const u8, .default = null }, + .{ .name = "wait_selector", .type = ?[:0]const u8, .default = null }, + }, + .shared_options = CommonOptions, + }, + .{ + .name = "mcp", + .options = .{ + .{ .name = "cdp_port", .type = ?u16, .default = null }, + }, + .shared_options = CommonOptions, + }, + .{ .name = "version", .options = .{} }, + .{ .name = "help", .options = .{} }, +}); + +pub const RunMode = Commands.Enum; +pub const Mode = Commands.Union; + mode: Mode, exec_name: []const u8, http_headers: HttpHeaders, -const Config = @This(); - pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config { var config = Config{ .mode = mode, @@ -66,56 +124,56 @@ pub fn deinit(self: *const Config, allocator: Allocator) void { pub fn tlsVerifyHost(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.tls_verify_host, + inline .serve, .fetch, .mcp => |opts| !opts.insecure_disable_tls_host_verification, else => unreachable, }; } pub fn obeyRobots(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots, + inline .serve, .fetch, .mcp => |opts| opts.obey_robots, else => unreachable, }; } pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy, + inline .serve, .fetch, .mcp => |opts| opts.http_proxy, else => unreachable, }; } pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.proxy_bearer_token, + inline .serve, .fetch, .mcp => |opts| opts.proxy_bearer_token, .help, .version => null, }; } pub fn httpMaxConcurrent(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.http_max_concurrent orelse 10, + inline .serve, .fetch, .mcp => |opts| opts.http_max_concurrent orelse 10, else => unreachable, }; } pub fn httpMaxHostOpen(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.http_max_host_open orelse 4, + inline .serve, .fetch, .mcp => |opts| opts.http_max_host_open orelse 4, else => unreachable, }; } pub fn httpConnectTimeout(self: *const Config) u31 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.http_connect_timeout orelse 0, + inline .serve, .fetch, .mcp => |opts| opts.http_connect_timeout orelse 0, else => unreachable, }; } pub fn httpTimeout(self: *const Config) u31 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.http_timeout orelse 5000, + inline .serve, .fetch, .mcp => |opts| opts.http_timeout orelse 5000, else => unreachable, }; } @@ -126,42 +184,42 @@ pub fn httpMaxRedirects(_: *const Config) u8 { pub fn httpMaxResponseSize(self: *const Config) ?usize { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.http_max_response_size, + inline .serve, .fetch, .mcp => |opts| opts.http_max_response_size, else => unreachable, }; } pub fn wsMaxConcurrent(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.ws_max_concurrent orelse 64, + inline .serve, .fetch, .mcp => |opts| opts.ws_max_concurrent orelse 8, else => unreachable, }; } pub fn logLevel(self: *const Config) ?log.Level { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.log_level, + inline .serve, .fetch, .mcp => |opts| opts.log_level, else => unreachable, }; } pub fn logFormat(self: *const Config) ?log.Format { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.log_format, + inline .serve, .fetch, .mcp => |opts| opts.log_format, else => unreachable, }; } -pub fn logFilterScopes(self: *const Config) ?[]const log.Scope { - return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.log_filter_scopes, - else => unreachable, - }; -} +//pub fn logFilterScopes(self: *const Config) ?[]const log.Scope { +// return switch (self.mode) { +// inline .serve, .fetch, .mcp => |opts| opts.log_filter_scopes, +// else => unreachable, +// }; +//} pub fn userAgentSuffix(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix, + inline .serve, .fetch, .mcp => |opts| opts.user_agent_suffix, .help, .version => null, }; } @@ -175,7 +233,7 @@ pub fn userAgent(self: *const Config) ?[]const u8 { pub fn httpCacheDir(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.http_cache_dir, + inline .serve, .fetch, .mcp => |opts| opts.http_cache_dir, else => null, }; } @@ -196,8 +254,8 @@ pub fn cookieJarFile(self: *const Config) ?[]const u8 { pub fn cdpTimeout(self: *const Config) usize { return switch (self.mode) { - .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, - .mcp => 10000, // Default timeout for MCP-CDP + .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1_000, + .mcp => 10_000, // Default timeout for MCP-CDP else => unreachable, }; } @@ -221,9 +279,9 @@ pub fn advertiseHost(self: *const Config) []const u8 { pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{ - .key_file = opts.common.web_bot_auth_key_file orelse return null, - .keyid = opts.common.web_bot_auth_keyid orelse return null, - .domain = opts.common.web_bot_auth_domain orelse return null, + .key_file = opts.web_bot_auth_key_file orelse return null, + .keyid = opts.web_bot_auth_keyid orelse return null, + .domain = opts.web_bot_auth_domain orelse return null, }, .help, .version => null, }; @@ -263,8 +321,8 @@ pub fn storageEngine(self: *const Config) ?Storage.EngineType { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.storage_engine, else => unreachable, - }; } + }; pub fn storageSqlitePath(self: *const Config) ?[:0]const u8 { return switch (self.mode) { @@ -273,28 +331,10 @@ pub fn storageSqlitePath(self: *const Config) ?[:0]const u8 { }; } -pub const Mode = union(RunMode) { - help: bool, // false when being printed because of an error - fetch: Fetch, - serve: Serve, - version: void, - mcp: Mcp, -}; - -pub const Serve = struct { - host: []const u8 = "127.0.0.1", - port: u16 = 9222, - advertise_host: ?[]const u8 = null, - timeout: u31 = 10, - cdp_max_connections: u16 = 16, - cdp_max_pending_connections: u16 = 128, - common: Common = .{}, -}; - -pub const Mcp = struct { - common: Common = .{}, - cdp_port: ?u16 = null, -}; +//pub const Mcp = struct { +// common: Common = .{}, +// cdp_port: ?u16 = null, +//}; pub const DumpFormat = enum { html, @@ -311,49 +351,6 @@ pub const WaitUntil = enum { done, }; -pub const Fetch = struct { - url: [:0]const u8, - dump_mode: ?DumpFormat = null, - common: Common = .{}, - with_base: bool = false, - with_frames: bool = false, - strip: dump.Opts.Strip = .{}, - wait_ms: u32 = 5000, - wait_until: ?WaitUntil = null, - wait_script: ?[:0]const u8 = null, - wait_selector: ?[:0]const u8 = null, -}; - -pub const Common = struct { - obey_robots: bool = false, - proxy_bearer_token: ?[:0]const u8 = null, - http_proxy: ?[:0]const u8 = null, - http_max_concurrent: ?u8 = null, - http_max_host_open: ?u8 = null, - http_timeout: ?u31 = null, - http_connect_timeout: ?u31 = null, - http_max_response_size: ?usize = null, - ws_max_concurrent: ?u8 = null, - tls_verify_host: bool = true, - log_level: ?log.Level = null, - log_format: ?log.Format = null, - log_filter_scopes: ?[]log.Scope = null, - user_agent_suffix: ?[]const u8 = null, - user_agent: ?[]const u8 = null, - http_cache_dir: ?[]const u8 = null, - cookie: ?[]const u8 = null, - cookie_jar: ?[]const u8 = null, - storage_engine: ?Storage.EngineType = null, - storage_sqlite_path: ?[:0]const u8 = null, - - web_bot_auth_key_file: ?[]const u8 = null, - web_bot_auth_keyid: ?[]const u8 = null, - web_bot_auth_domain: ?[]const u8 = null, - - block_private_networks: bool = false, - block_cidrs: ?[]const u8 = null, -}; - /// Pre-formatted HTTP headers for reuse across Http and Client. /// Must be initialized with an allocator that outlives all HTTP connections. pub const HttpHeaders = struct { @@ -525,12 +522,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'. \\ Defaults to no dump. \\ - \\--strip-mode Comma separated list of tag groups to remove from dump - \\ the dump. e.g. --strip-mode js,css + \\--strip Comma separated list of tag groups to remove from dump + \\ the dump. e.g. --strip js,css \\ - "js" script and link[as=script, rel=preload] \\ - "ui" includes img, picture, video, css and svg \\ - "css" includes style and link[rel=stylesheet] - \\ - "full" includes js, ui and css + \\ - "all" includes js, ui and css \\ \\--with-base Add a tag in dump. Defaults to false. \\ @@ -623,732 +620,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { std.process.exit(1); } +// TODO: Fix mode sniff regression. pub fn parseArgs(allocator: Allocator) !Config { - var args = try std.process.argsWithAllocator(allocator); - defer args.deinit(); - - const exec_name = try allocator.dupe(u8, std.fs.path.basename(args.next().?)); - - const mode_string = args.next() orelse ""; - const run_mode = std.meta.stringToEnum(RunMode, mode_string) orelse blk: { - const inferred_mode = inferMode(mode_string) orelse - return init(allocator, exec_name, .{ .help = false }); - // "command" wasn't a command but an option. We can't reset args, but - // we can create a new one. Not great, but this fallback is temporary - // as we transition to this command mode approach. - args.deinit(); - - args = try std.process.argsWithAllocator(allocator); - // skip the exec_name - _ = args.skip(); - - break :blk inferred_mode; - }; - - const mode: Mode = switch (run_mode) { - .help => .{ .help = true }, - .serve => .{ .serve = parseServeArgs(allocator, &args) catch - return init(allocator, exec_name, .{ .help = false }) }, - .fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch - return init(allocator, exec_name, .{ .help = false }) }, - .mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch - return init(allocator, exec_name, .{ .help = false }) }, - .version => .{ .version = {} }, - }; - return init(allocator, exec_name, mode); -} - -fn inferMode(opt: []const u8) ?RunMode { - if (opt.len == 0) { - return .serve; - } - - if (std.mem.startsWith(u8, opt, "--") == false) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--dump")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--noscript")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--strip-mode") or std.mem.eql(u8, opt, "--strip_mode")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--with-base") or std.mem.eql(u8, opt, "--with_base")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--with-frames") or std.mem.eql(u8, opt, "--with_frames")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--host")) { - return .serve; - } - - if (std.mem.eql(u8, opt, "--port")) { - return .serve; - } - - if (std.mem.eql(u8, opt, "--timeout")) { - return .serve; - } - - return null; -} - -fn parseServeArgs( - allocator: Allocator, - args: *std.process.ArgIterator, -) !Serve { - var serve: Serve = .{}; - - while (args.next()) |opt| { - if (std.mem.eql(u8, "--host", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--host" }); - return error.InvalidArgument; - }; - serve.host = try allocator.dupe(u8, str); - continue; - } - - if (std.mem.eql(u8, "--port", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--port" }); - return error.InvalidArgument; - }; - - serve.port = std.fmt.parseInt(u16, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = "--port", .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--advertise-host", opt) or std.mem.eql(u8, "--advertise_host", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - serve.advertise_host = try allocator.dupe(u8, str); - continue; - } - - if (std.mem.eql(u8, "--timeout", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--timeout" }); - return error.InvalidArgument; - }; - - serve.timeout = std.fmt.parseInt(u31, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = "--timeout", .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--cdp-max-connections", opt) or std.mem.eql(u8, "--cdp_max_connections", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--cdp-max-pending-connections", opt) or std.mem.eql(u8, "--cdp_max_pending_connections", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - serve.cdp_max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--cookie-jar", opt)) { - log.fatal(.app, "invalid argument value", .{ - .arg = opt, - .detail = "--cookie-jar is only available for fetch and mcp", - }); - return error.InvalidArgument; - } - - if (try parseCommonArg(allocator, opt, args, &serve.common)) { - continue; - } - - log.fatal(.app, "unknown argument", .{ .mode = "serve", .arg = opt }); - return error.UnknownOption; - } - - return serve; -} - -fn parseMcpArgs( - allocator: Allocator, - args: *std.process.ArgIterator, -) !Mcp { - var result: Mcp = .{}; - - while (args.next()) |opt| { - if (std.mem.eql(u8, "--cdp-port", opt) or std.mem.eql(u8, "--cdp_port", opt)) { - const str = args.next() orelse { - log.fatal(.mcp, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - result.cdp_port = std.fmt.parseInt(u16, str, 10) catch |err| { - log.fatal(.mcp, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (try parseCommonArg(allocator, opt, args, &result.common)) { - continue; - } - - log.fatal(.mcp, "unknown argument", .{ .mode = "mcp", .arg = opt }); - return error.UnknownOption; - } - - return result; -} - -fn parseFetchArgs( - allocator: Allocator, - args: *std.process.ArgIterator, -) !Fetch { - var dump_mode: ?DumpFormat = null; - var with_base: bool = false; - var with_frames: bool = false; - var url: ?[:0]const u8 = null; - var common: Common = .{}; - var strip: dump.Opts.Strip = .{}; - var wait_ms: u32 = 5000; - var wait_until: ?WaitUntil = null; - var wait_script: ?[:0]const u8 = null; - var wait_selector: ?[:0]const u8 = null; - - while (args.next()) |opt| { - if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_ms = std.fmt.parseInt(u32, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--wait-until", opt) or std.mem.eql(u8, "--wait_until", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_until = std.meta.stringToEnum(WaitUntil, str) orelse { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .val = str }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--wait-selector", opt) or std.mem.eql(u8, "--wait_selector", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_selector = try allocator.dupeZ(u8, str); - continue; - } - - if (std.mem.eql(u8, "--wait-script", opt) or std.mem.eql(u8, "--wait_script", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_script = try allocator.dupeZ(u8, str); - continue; - } - - if (std.mem.eql(u8, "--wait-script-file", opt) or std.mem.eql(u8, "--wait_script_file", opt)) { - const path = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_script = std.fs.cwd().readFileAllocOptions(allocator, path, 1024 * 1024, null, .of(u8), 0) catch |err| { - log.fatal(.app, "failed to read file", .{ .arg = opt, .path = path, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--dump", opt)) { - var peek_args = args.*; - if (peek_args.next()) |next_arg| { - if (std.meta.stringToEnum(DumpFormat, next_arg)) |mode| { - dump_mode = mode; - _ = args.next(); - } else { - dump_mode = .html; - } - } else { - dump_mode = .html; - } - continue; - } - - if (std.mem.eql(u8, "--noscript", opt)) { - log.warn(.app, "deprecation warning", .{ - .feature = "--noscript argument", - .hint = "use '--strip-mode js' instead", - }); - strip.js = true; - continue; - } - - if (std.mem.eql(u8, "--with-base", opt) or std.mem.eql(u8, "--with_base", opt)) { - with_base = true; - continue; - } - - if (std.mem.eql(u8, "--with-frames", opt) or std.mem.eql(u8, "--with_frames", opt)) { - with_frames = true; - continue; - } - - if (std.mem.eql(u8, "--strip-mode", opt) or std.mem.eql(u8, "--strip_mode", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - var it = std.mem.splitScalar(u8, str, ','); - while (it.next()) |part| { - const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); - if (std.mem.eql(u8, trimmed, "js")) { - strip.js = true; - } else if (std.mem.eql(u8, trimmed, "ui")) { - strip.ui = true; - } else if (std.mem.eql(u8, trimmed, "css")) { - strip.css = true; - } else if (std.mem.eql(u8, trimmed, "full")) { - strip.js = true; - strip.ui = true; - strip.css = true; - } else { - log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = trimmed }); - } - } - continue; - } - - if (try parseCommonArg(allocator, opt, args, &common)) { - continue; - } - - if (std.mem.startsWith(u8, opt, "--")) { - log.fatal(.app, "unknown argument", .{ .mode = "fetch", .arg = opt }); - return error.UnknownOption; - } - - if (url != null) { - log.fatal(.app, "duplicate fetch url", .{ .help = "only 1 URL can be specified" }); - return error.TooManyURLs; - } - url = try allocator.dupeZ(u8, opt); - } - - if (url == null) { - log.fatal(.app, "missing fetch url", .{ .help = "URL to fetch must be provided" }); - return error.MissingURL; - } - - return .{ - .url = url.?, - .dump_mode = dump_mode, - .strip = strip, - .common = common, - .with_base = with_base, - .with_frames = with_frames, - .wait_ms = wait_ms, - .wait_until = wait_until, - .wait_selector = wait_selector, - .wait_script = wait_script, - }; -} - -fn parseCommonArg( - allocator: Allocator, - opt: []const u8, - args: *std.process.ArgIterator, - common: *Common, -) !bool { - if (std.mem.eql(u8, "--insecure-disable-tls-host-verification", opt) or std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) { - common.tls_verify_host = false; - return true; - } - - if (std.mem.eql(u8, "--obey-robots", opt) or std.mem.eql(u8, "--obey_robots", opt)) { - common.obey_robots = true; - return true; - } - - if (std.mem.eql(u8, "--http-proxy", opt) or std.mem.eql(u8, "--http_proxy", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.http_proxy = try allocator.dupeZ(u8, str); - return true; - } - - if (std.mem.eql(u8, "--proxy-bearer-token", opt) or std.mem.eql(u8, "--proxy_bearer_token", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.proxy_bearer_token = try allocator.dupeZ(u8, str); - return true; - } - - if (std.mem.eql(u8, "--http-max-concurrent", opt) or std.mem.eql(u8, "--http_max_concurrent", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--http-max-host-open", opt) or std.mem.eql(u8, "--http_max_host_open", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--http-connect-timeout", opt) or std.mem.eql(u8, "--http_connect_timeout", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--http-timeout", opt) or std.mem.eql(u8, "--http_timeout", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--http-max-response-size", opt) or std.mem.eql(u8, "--http_max_response_size", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--ws-max-concurrent", opt) or std.mem.eql(u8, "--ws_max_concurrent", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.ws_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--log-level", opt) or std.mem.eql(u8, "--log_level", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.log_level = std.meta.stringToEnum(log.Level, str) orelse blk: { - if (std.mem.eql(u8, str, "error")) { - break :blk .err; - } - log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--log-format", opt) or std.mem.eql(u8, "--log_format", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.log_format = std.meta.stringToEnum(log.Format, str) orelse { - log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--log-filter-scopes", opt) or std.mem.eql(u8, "--log_filter_scopes", opt)) { - if (builtin.mode != .Debug) { - log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" }); - return false; - } - - const str = args.next() orelse { - // disables the default filters - common.log_filter_scopes = &.{}; - return true; - }; - - var arr: std.ArrayList(log.Scope) = .empty; - - var it = std.mem.splitScalar(u8, str, ','); - while (it.next()) |part| { - try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse { - log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = part }); - return false; - }); - } - common.log_filter_scopes = arr.items; - return true; - } - - if (std.mem.eql(u8, "--user-agent", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - validateUserAgent(str) catch |err| { - log.fatal(.app, "invalid value", .{ - .detail = "invalid user agent", - .arg = opt, - .err = err, - }); - return error.InvalidArgument; - }; - - common.user_agent = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--user-agent-suffix", opt) or std.mem.eql(u8, "--user_agent_suffix", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - if (common.user_agent != null) { - log.fatal(.app, "exclusive options", .{ - .arg = opt, - .detail = "--user-agent and --user-agent-suffix are exclusive", - }); - return error.InvalidArgument; - } - - for (str) |c| { - if (!std.ascii.isPrint(c)) { - log.fatal(.app, "not printable character", .{ .arg = opt }); - return error.InvalidArgument; - } - } - common.user_agent_suffix = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--web-bot-auth-key-file", opt) or std.mem.eql(u8, "--web_bot_auth_key_file", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.web_bot_auth_key_file = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--web-bot-auth-keyid", opt) or std.mem.eql(u8, "--web_bot_auth_keyid", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.web_bot_auth_keyid = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--web-bot-auth-domain", opt) or std.mem.eql(u8, "--web_bot_auth_domain", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.web_bot_auth_domain = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--http-cache-dir", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--http-cache-dir" }); - return error.InvalidArgument; - }; - common.http_cache_dir = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--storage-engine", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--storage-engine" }); - return error.InvalidArgument; - }; - common.storage_engine = std.meta.stringToEnum(Storage.EngineType, str) orelse { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .val = str }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--storage-sqlite-path", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--storage-sqlite-path" }); - return error.InvalidArgument; - }; - common.storage_sqlite_path = try allocator.dupeZ(u8, str); - return true; - } - - if (std.mem.eql(u8, "--block-private-networks", opt)) { - common.block_private_networks = true; - return true; - } - - if (std.mem.eql(u8, "--block-cidrs", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--block-cidrs" }); - return error.InvalidArgument; - }; - common.block_cidrs = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--cookie", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--cookie" }); - return error.InvalidArgument; - }; - common.cookie = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--cookie-jar", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--cookie-jar" }); - return error.InvalidArgument; - }; - common.cookie_jar = try allocator.dupe(u8, str); - return true; - } - - return false; -} - -pub fn validateUserAgent(ua: []const u8) !void { - for (ua) |c| { - if (!std.ascii.isPrint(c)) { - return error.NonPrintable; - } - } - - if (std.ascii.indexOfIgnoreCase(ua, "mozilla") != null) { - return error.Reserved; - } -} - -const testing = @import("testing.zig"); -test "Config: HttpHeaders - default user agent" { - var config = try Config.init(testing.allocator, "", .{ .serve = .{} }); - defer config.deinit(testing.allocator); - - try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent); - try testing.expectEqual("User-Agent: Lightpanda/1.0", config.http_headers.user_agent_header); - try testing.expect(config.http_headers.proxy_bearer_header == null); -} - -test "Config: HttpHeaders - custom user agent override" { - var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .user_agent = "MyBot/2.0" } } }); - defer config.deinit(testing.allocator); - - try testing.expectEqual("MyBot/2.0", config.http_headers.user_agent); - try testing.expectEqual("User-Agent: MyBot/2.0", config.http_headers.user_agent_header); -} - -test "Config: HttpHeaders - user agent suffix" { - var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .user_agent_suffix = "CustomSuffix/3.0" } } }); - defer config.deinit(testing.allocator); - - try testing.expectEqual("Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent); - try testing.expectEqual("User-Agent: Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent_header); -} - -test "Config: HttpHeaders - fetch mode default user agent" { - var config = try Config.init(testing.allocator, "", .{ .fetch = .{ .url = "https://example.com" } }); - defer config.deinit(testing.allocator); - try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent); -} - -test "Config: HttpHeaders - fetch mode custom user agent" { - var config = try Config.init(testing.allocator, "", .{ .fetch = .{ .url = "https://example.com", .common = .{ .user_agent = "FetchBot/1.0" } } }); - defer config.deinit(testing.allocator); - try testing.expectEqual("FetchBot/1.0", config.http_headers.user_agent); - try testing.expectEqual("User-Agent: FetchBot/1.0", config.http_headers.user_agent_header); -} - -test "Config: HttpHeaders - proxy bearer header" { - var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .proxy_bearer_token = "secret-token" } } }); - defer config.deinit(testing.allocator); - try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent); - try testing.expectEqual("Proxy-Authorization: Bearer secret-token", config.http_headers.proxy_bearer_header.?); + const exec_name, const command = try Commands.parse(allocator); + return .init(allocator, exec_name, command); } From 0e4ed2639fd64fb1c927719e72b6d4832d869d98 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 8 Apr 2026 17:43:48 +0300 Subject: [PATCH 06/43] `main`: changes to build, introduced couple regressions `help` command, `--dump` and `--log-filter-scopes` regressed, will come up with a solution for these. --- src/main.zig | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main.zig b/src/main.zig index c10c3b4b..41be4bb7 100644 --- a/src/main.zig +++ b/src/main.zig @@ -55,7 +55,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { switch (args.mode) { .help => { - args.printUsageAndExit(args.mode.help); + args.printUsageAndExit(true); return std.process.cleanExit(); }, .version => { @@ -72,9 +72,10 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { if (args.logFormat()) |lf| { log.opts.format = lf; } - if (args.logFilterScopes()) |lfs| { - log.opts.filter_scopes = lfs; - } + // TODO: Fix regression. + //if (args.logFilterScopes()) |lfs| { + // log.opts.filter_scopes = lfs; + //} // must be installed before any other threads const sighandler = try main_arena.create(SigHandler); @@ -118,14 +119,14 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { }, .fetch => |opts| { const url = opts.url; - log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump_mode, .url = url, .snapshot = app.snapshot.fromEmbedded() }); + log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump, .url = url, .snapshot = app.snapshot.fromEmbedded() }); var fetch_opts = lp.FetchOpts{ .wait_ms = opts.wait_ms, .wait_until = opts.wait_until, .wait_script = opts.wait_script, .wait_selector = opts.wait_selector, - .dump_mode = opts.dump_mode, + .dump_mode = opts.dump, .dump = .{ .strip = opts.strip, .with_base = opts.with_base, @@ -135,11 +136,11 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var stdout = std.fs.File.stdout(); var writer = stdout.writer(&.{}); - if (opts.dump_mode != null) { + if (opts.dump != null) { fetch_opts.writer = &writer.interface; } - var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, url, fetch_opts }); + var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, url.?, fetch_opts }); defer worker_thread.join(); app.network.run(); From 56b6fbe01195fb38b072df885d800513d0581bdd Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 13:48:44 +0300 Subject: [PATCH 07/43] `cli`: many improvements * Options with optional types are introduced to null by default * Options with boolean types cannot be optional (nullable) * Options with boolean types are introduced with false by default * introduce shortcuts for options that can be provided via single dash * introduce validators; custom parsing logic can be inserted for niche cases --- src/Config.zig | 74 ++++++++---- src/cli.zig | 319 +++++++++++++++++++++++++++++-------------------- 2 files changed, 239 insertions(+), 154 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 8fa53c4a..4bd67995 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -39,26 +39,42 @@ const Config = @This(); /// Common CLI args. const CommonOptions = .{ - .{ .name = "obey_robots", .type = bool, .default = false }, - .{ .name = "proxy_bearer_token", .type = ?[:0]const u8, .default = null }, - .{ .name = "http_proxy", .type = ?[:0]const u8, .default = null }, - .{ .name = "http_max_concurrent", .type = ?u8, .default = null }, - .{ .name = "http_max_host_open", .type = ?u8, .default = null }, - .{ .name = "http_timeout", .type = ?u31, .default = null }, - .{ .name = "http_connect_timeout", .type = ?u31, .default = null }, - .{ .name = "http_max_response_size", .type = ?usize, .default = null }, - .{ .name = "ws_max_concurrent", .type = ?u8, .default = null }, - .{ .name = "insecure_disable_tls_host_verification", .type = bool, .default = false }, - .{ .name = "log_level", .type = ?log.Level, .default = null }, - .{ .name = "log_format", .type = ?log.Format, .default = null }, + .{ .name = "obey_robots", .type = bool }, + .{ .name = "proxy_bearer_token", .type = ?[:0]const u8 }, + .{ .name = "http_proxy", .type = ?[:0]const u8 }, + .{ .name = "http_max_concurrent", .type = ?u8 }, + .{ .name = "http_max_host_open", .type = ?u8 }, + .{ .name = "http_timeout", .type = ?u31 }, + .{ .name = "http_connect_timeout", .type = ?u31 }, + .{ .name = "http_max_response_size", .type = ?usize }, + .{ .name = "ws_max_concurrent", .type = ?u8 }, + .{ .name = "insecure_disable_tls_host_verification", .type = bool }, + .{ .name = "log_level", .type = ?log.Level }, + .{ .name = "log_format", .type = ?log.Format }, // TODO: log_filter_scopes (?[]log.Scope) — parser doesn't yet support `multiple` for enums. - .{ .name = "user_agent_suffix", .type = ?[]const u8, .default = null }, - .{ .name = "http_cache_dir", .type = ?[]const u8, .default = null }, - .{ .name = "web_bot_auth_key_file", .type = ?[]const u8, .default = null }, - .{ .name = "web_bot_auth_keyid", .type = ?[]const u8, .default = null }, - .{ .name = "web_bot_auth_domain", .type = ?[]const u8, .default = null }, + .{ .name = "user_agent_suffix", .type = ?[]const u8 }, + .{ .name = "http_cache_dir", .type = ?[]const u8 }, + .{ .name = "web_bot_auth_key_file", .type = ?[]const u8 }, + .{ .name = "web_bot_auth_keyid", .type = ?[]const u8 }, + .{ .name = "web_bot_auth_domain", .type = ?[]const u8 }, }; +fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { + // Peek next argument. + var peek_args = args.*; + if (peek_args.next()) |next_arg| { + // Skip the argument we peek if successful. + defer _ = args.next(); + return std.meta.stringToEnum(DumpFormat, next_arg) orelse { + return error.UnknownDumpOption; + }; + } + + // Means we couldn't get something like `--dump html` but we do have + // `--dump`; which should fall to `html` by default. + return .html; +} + /// Definition for all the commands and its arguments. See @cli.zig for further. const Commands = cli.Builder(.{ .{ @@ -66,7 +82,7 @@ const Commands = cli.Builder(.{ .options = .{ .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, .{ .name = "port", .type = u16, .default = 9222 }, - .{ .name = "advertise_host", .type = ?[]const u8, .default = null }, + .{ .name = "advertise_host", .type = ?[]const u8 }, .{ .name = "timeout", .type = u31, .default = 10 }, .{ .name = "cdp_max_connections", .type = u16, .default = 16 }, .{ .name = "cdp_max_pending_connections", .type = u16, .default = 128 }, @@ -76,24 +92,30 @@ const Commands = cli.Builder(.{ .{ .name = "fetch", .aliases = .{ "f", "get" }, - // Fetch only; this argument can be given anywhere. + // This argument can be given out of order. .positional = .{ .name = "url", .type = ?[:0]const u8 }, .options = .{ - .{ .name = "dump", .type = ?DumpFormat, .default = null }, - .{ .name = "with_base", .type = bool, .default = false }, - .{ .name = "with_frames", .type = bool, .default = false }, + .{ + .name = "dump", + // Prefixed by `-` (single dash). + .shortcuts = .{"d"}, + .type = ?DumpFormat, + .validator = dumpValidator, + }, + .{ .name = "with_base", .type = bool }, + .{ .name = "with_frames", .type = bool }, .{ .name = "strip", .type = dump.Opts.Strip, .default = dump.Opts.Strip{} }, .{ .name = "wait_ms", .type = u32, .default = 5_000 }, - .{ .name = "wait_until", .type = ?WaitUntil, .default = null }, - .{ .name = "wait_script", .type = ?[:0]const u8, .default = null }, - .{ .name = "wait_selector", .type = ?[:0]const u8, .default = null }, + .{ .name = "wait_until", .type = ?WaitUntil }, + .{ .name = "wait_script", .type = ?[:0]const u8 }, + .{ .name = "wait_selector", .type = ?[:0]const u8 }, }, .shared_options = CommonOptions, }, .{ .name = "mcp", .options = .{ - .{ .name = "cdp_port", .type = ?u16, .default = null }, + .{ .name = "cdp_port", .type = ?u16 }, }, .shared_options = CommonOptions, }, diff --git a/src/cli.zig b/src/cli.zig index 358d14f7..431a32cf 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -45,24 +45,56 @@ pub fn Builder(comptime commands: anytype) type { var fields: [options.len]std.builtin.Type.StructField = undefined; inline for (options, 0..) |option, j| { + // Whether prefer `ArrayList` for the option. const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple; - const T, const default = type_and_default: { + // Whether option has a default value. + const has_default = @hasField(@TypeOf(option), "default"); + + const T = if (is_multiple) std.ArrayList(option.type) else option.type; + + const default = blk: { if (is_multiple) { // We currently don't allow default values for lists. - if (@hasField(@TypeOf(option), "default")) unreachable; - // If `multiple` provided, prefer `ArrayList`. - const T = std.ArrayList(option.type); - break :type_and_default .{ T, &@as(T, .{}) }; + if (has_default) { + @compileError("`default` is not allowed for lists"); + } + // Multiples are always initialized the same. + break :blk @as(*const anyopaque, @ptrCast(&@as(T, .{}))); } - const T = option.type; - break :type_and_default .{ T, &@as(T, option.default) }; + switch (@typeInfo(option.type)) { + .optional => |optional| { + if (optional.child == bool) { + @compileError("?bool is not supported, prefer enum"); + } + + // If type is an optional type without default value, prefer null. + if (!has_default) { + break :blk @as(*const anyopaque, @ptrCast(&@as(T, null))); + } + // We have default value for an optional. + break :blk @as(*const anyopaque, @ptrCast(&@as(T, option.default))); + }, + .bool => { + if (has_default) { + @compileError("booleans are always `false` by default"); + } + // Booleans are always initalized false. + break :blk @as(*const anyopaque, @ptrCast(&@as(T, false))); + }, + inline else => { + if (!has_default) { + @compileError("option `" ++ option.name ++ "` is not optional type and has no default value"); + } + break :blk @as(*const anyopaque, @ptrCast(&@as(T, option.default))); + }, + } }; fields[j] = .{ .name = option.name, .type = T, - .default_value_ptr = @ptrCast(default), + .default_value_ptr = default, .is_comptime = false, .alignment = @alignOf(T), }; @@ -78,26 +110,28 @@ pub fn Builder(comptime commands: anytype) type { const Command = @TypeOf(command); const options = command.options; + const fields = optionsToStructFields(options) ++ + (if (@hasField(Command, "shared_options")) + optionsToStructFields(command.shared_options) + else + .{}) ++ + (if (@hasField(Command, "positional")) + [1]std.builtin.Type.StructField{ + .{ + .name = command.positional.name, + .type = command.positional.type, + .default_value_ptr = @ptrCast(&@as(command.positional.type, null)), + .is_comptime = false, + .alignment = @alignOf(command.positional.type), + }, + } + else + .{}); + const T = @Type(.{ .@"struct" = .{ .decls = &.{}, - .fields = &(optionsToStructFields(options) ++ - (if (@hasField(Command, "shared_options")) - optionsToStructFields(command.shared_options) - else - .{}) ++ - (if (@hasField(Command, "positional")) - [1]std.builtin.Type.StructField{ - .{ - .name = command.positional.name, - .type = command.positional.type, - .default_value_ptr = @ptrCast(&@as(command.positional.type, null)), - .is_comptime = false, - .alignment = @alignOf(command.positional.type), - }, - } - else - .{})), + .fields = &fields, .is_tuple = false, .layout = .auto, }, @@ -164,19 +198,36 @@ pub fn Builder(comptime commands: anytype) type { }; iter_args: while (args.next()) |option_name| { inline for (options) |option| { - // We allow both `--my-option` and `--my_option` variants; - // assuming given `option` struct prefer snake_case for name. - const kebab_cased = comptime blk: { - var output: [option.name.len]u8 = undefined; - @memcpy(&output, option.name); - std.mem.replaceScalar(u8, &output, '_', '-'); - break :blk output; + // Match an option. + const match = blk: { + // We allow both `--my-option` and `--my_option` variants; + // assuming given `option` struct prefer snake_case for `name`. + const kebab_cased = comptime casing: { + var output: [option.name.len]u8 = undefined; + @memcpy(&output, option.name); + std.mem.replaceScalar(u8, &output, '_', '-'); + break :casing output; + }; + + const match = + std.mem.eql(u8, option_name, "--" ++ option.name) or + std.mem.eql(u8, option_name, "--" ++ kebab_cased); + + // Name not matched; try shortcuts if provided. + if (!match) { + if (@hasField(@TypeOf(option), "shortcuts")) { + inline for (option.shortcuts) |shortcut| { + if (std.mem.eql(u8, option_name, "-" ++ shortcut)) { + break :blk true; + } + } + } + } + + break :blk match; }; - // Match an option. - if (std.mem.eql(u8, option_name, "--" ++ option.name) or - std.mem.eql(u8, option_name, "--" ++ kebab_cased)) - { + if (match) { const T = option.type; const option_info = blk: { const info = @typeInfo(T); @@ -187,117 +238,129 @@ pub fn Builder(comptime commands: anytype) type { // TODO: Support multiples. const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple; + const has_validator = @hasField(@TypeOf(option), "validator"); - switch (option_info) { - .int => |int| { - const Int = std.meta.Int(int.signedness, int.bits); - // TODO: Return correct errors. - const v = try std.fmt.parseInt(Int, args.next().?, 10); + // Prefer custom validator logic instead. + if (has_validator) { + const v = try @call(.auto, option.validator, .{ allocator, args }); - if (is_multiple) { - // Push to ArrayList. - try @field(c, option.name).append(allocator, v); - } else { - @field(c, option.name) = v; - } - }, - .pointer => |pointer| { - const not_u8_slice = pointer.child != u8 or pointer.size != .slice; - if (not_u8_slice) { - @compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported"); - } + if (is_multiple) { + try @field(c, option.name).append(allocator, v); + } else { + @field(c, option.name) = v; + } + } else { + switch (option_info) { + .int => |int| { + const Int = std.meta.Int(int.signedness, int.bits); + // TODO: Return correct errors. + const v = try std.fmt.parseInt(Int, args.next().?, 10); - // TODO: Return error. - const str = args.next().?; - - const v = blk: { - // DupeZ branch. - if (comptime pointer.sentinel()) |sentinel| { - const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1); - @memcpy(buf[0..str.len], str); - buf[str.len] = sentinel; - break :blk buf[0..str.len :sentinel]; + if (is_multiple) { + // Push to ArrayList. + try @field(c, option.name).append(allocator, v); + } else { + @field(c, option.name) = v; + } + }, + .pointer => |pointer| { + const not_u8_slice = pointer.child != u8 or pointer.size != .slice; + if (not_u8_slice) { + @compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported"); } - // Dupe branch. - const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len); - @memcpy(buf, str); - break :blk buf; - }; + // TODO: Return error. + const str = args.next().?; - if (is_multiple) { - try @field(c, option.name).append(allocator, v); - } else { - @field(c, option.name) = v; - } - }, - .@"struct" => |_struct| { - // Don't support multiple for structs for now. - if (is_multiple) { - @compileError("multiple option is not supported for structs"); - } + const v = blk: { + // DupeZ branch. + if (comptime pointer.sentinel()) |sentinel| { + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1); + @memcpy(buf[0..str.len], str); + buf[str.len] = sentinel; + break :blk buf[0..str.len :sentinel]; + } - const not_packed = _struct.layout != .@"packed"; - if (not_packed) { - @compileError("only packed structs are allowed"); - } + // Dupe branch. + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len); + @memcpy(buf, str); + break :blk buf; + }; - // TODO: Return error. - const str = args.next().?; + if (is_multiple) { + try @field(c, option.name).append(allocator, v); + } else { + @field(c, option.name) = v; + } + }, + .@"struct" => |_struct| { + // Don't support multiple for structs for now. + if (is_multiple) { + @compileError("multiple option is not supported for structs"); + } - if (std.mem.eql(u8, str, "all")) { - // "all" sets all the fields of packed struct. - const Int = _struct.backing_integer.?; - @field(c, option.name) = @bitCast(@as(Int, std.math.maxInt(Int))); - } else { - // Parse given args. - var it = std.mem.splitScalar(u8, str, ','); - outer: while (it.next()) |part| { - const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); + const not_packed = _struct.layout != .@"packed"; + if (not_packed) { + @compileError("only packed structs are allowed"); + } - inline for (_struct.fields) |f| { - std.debug.assert(f.type == bool); + // TODO: Return error. + const str = args.next().?; - if (std.mem.eql(u8, trimmed, @as([]const u8, f.name))) { - @field(@field(c, option.name), f.name) = true; - continue :outer; + if (std.mem.eql(u8, str, "all")) { + // "all" sets all the fields of packed struct. + const Int = _struct.backing_integer.?; + @field(c, option.name) = @bitCast(@as(Int, std.math.maxInt(Int))); + } else { + // Parse given args. + var it = std.mem.splitScalar(u8, str, ','); + outer: while (it.next()) |part| { + const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); + + inline for (_struct.fields) |f| { + std.debug.assert(f.type == bool); + + if (std.mem.eql(u8, trimmed, @as([]const u8, f.name))) { + @field(@field(c, option.name), f.name) = true; + continue :outer; + } } } } - } - }, - .@"enum" => { - if (is_multiple) { - @compileError("multiple option is not supported for enums"); - } + }, + .@"enum" => { + if (is_multiple) { + @compileError("multiple option is not supported for enums"); + } - const E = switch (@typeInfo(T)) { - .optional => |optional| optional.child, - inline else => T, - }; - - // This type only, we peek ahead to check if there's a following arg. - // If there isn't, the default is set already. - var peek_args = args.*; - if (peek_args.next()) |next_arg| { - const v = std.meta.stringToEnum(E, next_arg) orelse { - return error.UnknownArgument; + const E = switch (@typeInfo(T)) { + .optional => |optional| optional.child, + inline else => T, }; - // Discard. - _ = args.next(); - @field(c, option.name) = v; - } - }, - .bool => { - if (is_multiple) { - @compileError("multiple option is not supported for booleans"); - } + // This type only, we peek ahead to check if there's a following arg. + // If there isn't, the default is set already. + var peek_args = args.*; + if (peek_args.next()) |next_arg| { + const v = std.meta.stringToEnum(E, next_arg) orelse { + return error.UnknownArgument; + }; + // Discard. + _ = args.next(); - @field(c, option.name) = true; - }, + @field(c, option.name) = v; + } + }, + .bool => { + if (is_multiple) { + @compileError("multiple option is not supported for booleans"); + } - else => {}, + @field(c, option.name) = true; + }, + + else => {}, + } } continue :iter_args; From b01e93502ec5de0c4017ee72ad8c683fcf5dab2a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 15:14:37 +0300 Subject: [PATCH 08/43] `cli`: revert enum specific peek ahead logic This was needed before the introduction of `validator`; doesn't make sense now. --- src/cli.zig | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 431a32cf..d1794785 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -329,25 +329,19 @@ pub fn Builder(comptime commands: anytype) type { } }, .@"enum" => { - if (is_multiple) { - @compileError("multiple option is not supported for enums"); - } - const E = switch (@typeInfo(T)) { .optional => |optional| optional.child, inline else => T, }; - // This type only, we peek ahead to check if there's a following arg. - // If there isn't, the default is set already. - var peek_args = args.*; - if (peek_args.next()) |next_arg| { - const v = std.meta.stringToEnum(E, next_arg) orelse { - return error.UnknownArgument; - }; - // Discard. - _ = args.next(); + // TODO: Return errors. + const v = std.meta.stringToEnum(E, args.next().?) orelse { + return error.UnknownArgument; + }; + if (is_multiple) { + try @field(c, option.name).append(allocator, v); + } else { @field(c, option.name) = v; } }, From 10914d62888fd542ff7d101301a50237c8ad9067 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 15:15:53 +0300 Subject: [PATCH 09/43] `cli`: fix `--log-filter-scopes` regression --- src/Config.zig | 28 +++++++++++++++++++++------- src/cli.zig | 8 +++++--- src/main.zig | 7 +++---- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 4bd67995..6d3187e4 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -37,6 +37,20 @@ pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140; const Config = @This(); +fn logFilterScopesValidator(allocator: Allocator, args: *std.process.ArgIterator, list: *std.ArrayList(log.Scope)) !void { + const str = args.next() orelse return error.InvalidOption; + + var it = std.mem.splitScalar(u8, str, ','); + while (it.next()) |part| { + const v = std.meta.stringToEnum(log.Scope, part) orelse { + log.fatal(.app, "invalid option choice", .{ .arg = "log-filter-scopes", .value = part }); + return error.InvalidOption; + }; + + try list.append(allocator, v); + } +} + /// Common CLI args. const CommonOptions = .{ .{ .name = "obey_robots", .type = bool }, @@ -51,7 +65,7 @@ const CommonOptions = .{ .{ .name = "insecure_disable_tls_host_verification", .type = bool }, .{ .name = "log_level", .type = ?log.Level }, .{ .name = "log_format", .type = ?log.Format }, - // TODO: log_filter_scopes (?[]log.Scope) — parser doesn't yet support `multiple` for enums. + .{ .name = "log_filter_scopes", .type = log.Scope, .multiple = true, .validator = logFilterScopesValidator }, .{ .name = "user_agent_suffix", .type = ?[]const u8 }, .{ .name = "http_cache_dir", .type = ?[]const u8 }, .{ .name = "web_bot_auth_key_file", .type = ?[]const u8 }, @@ -232,12 +246,12 @@ pub fn logFormat(self: *const Config) ?log.Format { }; } -//pub fn logFilterScopes(self: *const Config) ?[]const log.Scope { -// return switch (self.mode) { -// inline .serve, .fetch, .mcp => |opts| opts.log_filter_scopes, -// else => unreachable, -// }; -//} +pub fn logFilterScopes(self: *const Config) std.ArrayList(log.Scope) { + return switch (self.mode) { + inline .serve, .fetch, .mcp => |opts| opts.log_filter_scopes, + else => unreachable, + }; +} pub fn userAgentSuffix(self: *const Config) ?[]const u8 { return switch (self.mode) { diff --git a/src/cli.zig b/src/cli.zig index d1794785..124dbaa2 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -242,11 +242,13 @@ pub fn Builder(comptime commands: anytype) type { // Prefer custom validator logic instead. if (has_validator) { - const v = try @call(.auto, option.validator, .{ allocator, args }); - + const validator = option.validator; if (is_multiple) { - try @field(c, option.name).append(allocator, v); + // Pass the list. + try @call(.auto, validator, .{ allocator, args, &@field(c, option.name) }); } else { + // Receive the value from return. + const v = try @call(.auto, validator, .{ allocator, args }); @field(c, option.name) = v; } } else { diff --git a/src/main.zig b/src/main.zig index 41be4bb7..b78a730e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -72,10 +72,9 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { if (args.logFormat()) |lf| { log.opts.format = lf; } - // TODO: Fix regression. - //if (args.logFilterScopes()) |lfs| { - // log.opts.filter_scopes = lfs; - //} + + // Set log filter scopes. + log.opts.filter_scopes = args.logFilterScopes().items; // must be installed before any other threads const sighandler = try main_arena.create(SigHandler); From 1a68ea7370aed7441726859774196968f29e1d79 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 15:16:23 +0300 Subject: [PATCH 10/43] `cli`: better handle unknown arguments --- src/cli.zig | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 124dbaa2..4ef5c3e8 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -363,11 +363,6 @@ pub fn Builder(comptime commands: anytype) type { } } - // An option we don't know of. - if (std.mem.startsWith(u8, option_name, "--")) { - return error.UnkownOption; - } - // Parse positional arg if provided; can be given out of order: // // lightpanda fetch --wait-ms 2_000 "https://lightpanda.io" --dump "html" @@ -410,6 +405,9 @@ pub fn Builder(comptime commands: anytype) type { }, inline else => @compileError("not supported"), } + } else { + // An option we don't know of. + return error.UnknownOption; } } From 886448aaa31e02223226a16b77127424425dd738 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 15:17:00 +0300 Subject: [PATCH 11/43] `Commands`: add shortcuts for `host` and `port` of `serve` --- src/Config.zig | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 6d3187e4..7f3aedc8 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -94,8 +94,8 @@ const Commands = cli.Builder(.{ .{ .name = "serve", .options = .{ - .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, - .{ .name = "port", .type = u16, .default = 9222 }, + .{ .name = "host", .shortcuts = .{"h"}, .type = []const u8, .default = "127.0.0.1" }, + .{ .name = "port", .shortcuts = .{"p"}, .type = u16, .default = 9222 }, .{ .name = "advertise_host", .type = ?[]const u8 }, .{ .name = "timeout", .type = u31, .default = 10 }, .{ .name = "cdp_max_connections", .type = u16, .default = 16 }, @@ -109,13 +109,7 @@ const Commands = cli.Builder(.{ // This argument can be given out of order. .positional = .{ .name = "url", .type = ?[:0]const u8 }, .options = .{ - .{ - .name = "dump", - // Prefixed by `-` (single dash). - .shortcuts = .{"d"}, - .type = ?DumpFormat, - .validator = dumpValidator, - }, + .{ .name = "dump", .shortcuts = .{"d"}, .type = ?DumpFormat, .validator = dumpValidator }, .{ .name = "with_base", .type = bool }, .{ .name = "with_frames", .type = bool }, .{ .name = "strip", .type = dump.Opts.Strip, .default = dump.Opts.Strip{} }, From dc57096995d3a918ceca8f6af3ba2bdb31a3c3f5 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 15:23:33 +0300 Subject: [PATCH 12/43] `main_legacy_test`: fix regression --- src/main_legacy_test.zig | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index baa0275b..6c56c510 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -33,10 +33,8 @@ pub fn main() !void { } lp.log.opts.level = .warn; const config = try lp.Config.init(allocator, "legacy-test", .{ .serve = .{ - .common = .{ - .tls_verify_host = false, - .user_agent_suffix = "internal-tester", - }, + .insecure_disable_tls_host_verification = true, + .user_agent_suffix = "internal-tester", } }); defer config.deinit(allocator); From 059d21f7fde5afcfce65edf438d682cac77ae0ed Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 17:04:11 +0300 Subject: [PATCH 13/43] `cli`: return errors if next argument not found --- src/cli.zig | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 4ef5c3e8..289d49b2 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -157,8 +157,7 @@ pub fn Builder(comptime commands: anytype) type { const exec_name = std.fs.path.basename(args.next().?); - // TODO: Return error. - const cmd_str: []const u8 = args.next().?; + const cmd_str: []const u8 = args.next() orelse return error.MissingCommand; inline for (commands) |command| { // Command name together with it's aliases. const with_aliases = blk: { @@ -176,8 +175,7 @@ pub fn Builder(comptime commands: anytype) type { } } - // TODO: Unknown command. - @panic("unknown command"); + return error.UnknownCommand; } /// Parses the command with its options. @@ -236,7 +234,6 @@ pub fn Builder(comptime commands: anytype) type { break :blk info; }; - // TODO: Support multiples. const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple; const has_validator = @hasField(@TypeOf(option), "validator"); @@ -255,8 +252,7 @@ pub fn Builder(comptime commands: anytype) type { switch (option_info) { .int => |int| { const Int = std.meta.Int(int.signedness, int.bits); - // TODO: Return correct errors. - const v = try std.fmt.parseInt(Int, args.next().?, 10); + const v = try std.fmt.parseInt(Int, args.next() orelse return error.MissingArgument, 10); if (is_multiple) { // Push to ArrayList. @@ -271,10 +267,9 @@ pub fn Builder(comptime commands: anytype) type { @compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported"); } - // TODO: Return error. - const str = args.next().?; - const v = blk: { + const str = args.next() orelse return error.MissingArgument; + // DupeZ branch. if (comptime pointer.sentinel()) |sentinel| { const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1); @@ -306,8 +301,7 @@ pub fn Builder(comptime commands: anytype) type { @compileError("only packed structs are allowed"); } - // TODO: Return error. - const str = args.next().?; + const str = args.next() orelse return error.MissingArgument; if (std.mem.eql(u8, str, "all")) { // "all" sets all the fields of packed struct. @@ -336,8 +330,7 @@ pub fn Builder(comptime commands: anytype) type { inline else => T, }; - // TODO: Return errors. - const v = std.meta.stringToEnum(E, args.next().?) orelse { + const v = std.meta.stringToEnum(E, args.next() orelse return error.MissingArgument) orelse { return error.UnknownArgument; }; @@ -372,7 +365,7 @@ pub fn Builder(comptime commands: anytype) type { // Already given one. if (@field(c, positional.name) != null) { - return error.TooManyPositionalArgs; + return error.TooManyPositionalArguments; } // The positional must be an optional type. From baa0d83fa71c974675a70d8078028f274a64317f Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 17:15:31 +0300 Subject: [PATCH 14/43] `cli`: positional argument must not be null after parsing --- src/cli.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/cli.zig b/src/cli.zig index 289d49b2..ec27f932 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -404,6 +404,12 @@ pub fn Builder(comptime commands: anytype) type { } } + // Parsing is complete and positional is null. + const positional_is_null = @hasField(@TypeOf(command), "positional") and @field(c, command.positional.name) == null; + if (positional_is_null) { + return error.MissingArgument; + } + return @unionInit(Union, command.name, c); } }; From 85e624356b086da5c47ccf634e6f26e95a114b8e Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 17:24:28 +0300 Subject: [PATCH 15/43] `Commands`: more aliases --- src/Config.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 7f3aedc8..70f62ba9 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -94,8 +94,8 @@ const Commands = cli.Builder(.{ .{ .name = "serve", .options = .{ - .{ .name = "host", .shortcuts = .{"h"}, .type = []const u8, .default = "127.0.0.1" }, - .{ .name = "port", .shortcuts = .{"p"}, .type = u16, .default = 9222 }, + .{ .name = "host", .shortcuts = .{ "h", "H" }, .type = []const u8, .default = "127.0.0.1" }, + .{ .name = "port", .shortcuts = .{ "p", "P" }, .type = u16, .default = 9222 }, .{ .name = "advertise_host", .type = ?[]const u8 }, .{ .name = "timeout", .type = u31, .default = 10 }, .{ .name = "cdp_max_connections", .type = u16, .default = 16 }, @@ -109,7 +109,7 @@ const Commands = cli.Builder(.{ // This argument can be given out of order. .positional = .{ .name = "url", .type = ?[:0]const u8 }, .options = .{ - .{ .name = "dump", .shortcuts = .{"d"}, .type = ?DumpFormat, .validator = dumpValidator }, + .{ .name = "dump", .shortcuts = .{ "d", "D" }, .type = ?DumpFormat, .validator = dumpValidator }, .{ .name = "with_base", .type = bool }, .{ .name = "with_frames", .type = bool }, .{ .name = "strip", .type = dump.Opts.Strip, .default = dump.Opts.Strip{} }, @@ -127,8 +127,8 @@ const Commands = cli.Builder(.{ }, .shared_options = CommonOptions, }, - .{ .name = "version", .options = .{} }, - .{ .name = "help", .options = .{} }, + .{ .name = "version", .aliases = .{"v"}, .options = .{} }, + .{ .name = "help", .aliases = .{ "h", "?" }, .options = .{} }, }); pub const RunMode = Commands.Enum; From 07351f0e409f1835d1e4c06838c12143a376773d Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 17:25:07 +0300 Subject: [PATCH 16/43] `cli`: add an explanatory doc-comment that goes in depth of the API --- src/cli.zig | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/cli.zig b/src/cli.zig index ec27f932..3041b9c6 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -19,6 +19,110 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +/// Comptime CLI builder that generates a tagged union parser from a +/// declarative command recipe. Each command becomes a union variant whose +/// payload is a struct with one field per option. +/// +/// ## Command descriptor fields +/// +/// - `name: []const u8` — canonical command name on the command line. +/// - `options: tuple` — tuple of option descriptors (see below). Use `.{}` for none. +/// - `aliases: tuple` (optional) — alternative names for the command. +/// - `shared_options: tuple` (optional) — extra options merged into this +/// command. Useful for common flags shared across commands. +/// - `positional: struct` (optional) — a single positional argument with +/// `.name` and `.type`. Type must be an optional pointer-to-u8 slice +/// (e.g. `?[:0]const u8`). Positionals can appear anywhere in argv. +/// +/// ## Option descriptor fields +/// +/// - `name: []const u8` — snake_case field name. Both `--snake_case` and +/// `--kebab-case` are accepted on the command line. +/// - `type` — the Zig type of the parsed value (see supported types below). +/// - `default` (optional) — compile-time default when the flag is absent. +/// Rules vary by type; see the defaults section below. +/// - `shortcuts: tuple` (optional) — single-character short flags. Each +/// shortcut is matched as `-X` on the command line. +/// - `multiple: bool` (optional) — when `true`, the field becomes a +/// `std.ArrayList(type)` and each occurrence appends. +/// - `validator: fn` (optional) — custom parse function that replaces the +/// built-in type switch. See the validator section below. +/// +/// ## Supported types and their defaults +/// +/// - `bool` — presence sets `true`; always defaults to `false`. +/// Specifying `default` is a compile error. `?bool` is not allowed. +/// - Integers (`u8`, `u16`, `u31`, `usize`, etc.) — parsed with +/// `std.fmt.parseInt`. Requires `default` unless wrapped in `?`. +/// - `[]const u8`, `[:0]const u8` (and mutable variants) — string slices, +/// duped from argv. Sentinel is preserved. Requires `default` unless `?`. +/// - Enums — parsed via `std.meta.stringToEnum`. Requires `default` unless `?`. +/// - Packed structs of `bool` fields — parsed from a comma-separated list +/// (e.g. `--strip js,css`). The literal `"all"` sets every field. +/// Requires `default`. +/// - Optional types default to `null` when `default` is omitted. +/// +/// ## Validators +/// +/// A `validator` is a custom parse function that takes over argument +/// consumption for an option. The expected signature depends on whether +/// `multiple` is set: +/// +/// - Single: `fn (Allocator, *ArgIterator) !T` — returns the parsed value. +/// - Multiple: `fn (Allocator, *ArgIterator, *std.ArrayList(T)) !void` — +/// appends directly into the list. +/// +/// When a validator is present, the built-in type switch is skipped entirely. +/// +/// ## Example +/// +/// ```zig +/// const StripMode = packed struct(u2) { +/// js: bool = false, +/// css: bool = false, +/// }; +/// +/// const WaitUntil = enum { load, domcontentloaded, networkidle }; +/// +/// const CommonOptions = .{ +/// .{ .name = "verbose", .type = bool }, +/// .{ .name = "log_level", .type = ?log.Level }, +/// .{ .name = "timeout", .type = u31, .default = 30 }, +/// }; +/// +/// const Cli = cli.Builder(.{ +/// .{ +/// .name = "serve", +/// .aliases = .{"s"}, +/// .options = .{ +/// .{ .name = "host", .shortcuts = .{"h"}, .type = []const u8, .default = "127.0.0.1" }, +/// .{ .name = "port", .shortcuts = .{"p"}, .type = u16, .default = 9222 }, +/// }, +/// .shared_options = CommonOptions, +/// }, +/// .{ +/// .name = "fetch", +/// .positional = .{ .name = "url", .type = ?[:0]const u8 }, +/// .options = .{ +/// .{ .name = "dump", .type = ?DumpFormat, .validator = dumpValidator }, +/// .{ .name = "strip", .type = StripMode, .default = .{} }, +/// .{ .name = "wait_until", .type = ?WaitUntil }, +/// .{ .name = "extra_header", .type = []const u8, .multiple = true }, +/// }, +/// .shared_options = CommonOptions, +/// }, +/// .{ .name = "version", .options = .{} }, +/// .{ .name = "help", .aliases = .{ "h", "?" }, .options = .{} }, +/// }); +/// +/// const _, const cmd = try Cli.parse(arena); +/// switch (cmd) { +/// .serve => |opts| listen(opts.host, opts.port), +/// .fetch => |opts| fetch(opts.url.?, opts.dump), +/// .version => printVersion(), +/// .help => printHelp(), +/// } +/// ``` pub fn Builder(comptime commands: anytype) type { return struct { const Self = @This(); From f5b9bdb51b47659047335fbb088a567a8d5eb496 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 17:41:27 +0300 Subject: [PATCH 17/43] rebase and backport new feature from main --- src/Config.zig | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 70f62ba9..9f63cda4 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -71,6 +71,9 @@ const CommonOptions = .{ .{ .name = "web_bot_auth_key_file", .type = ?[]const u8 }, .{ .name = "web_bot_auth_keyid", .type = ?[]const u8 }, .{ .name = "web_bot_auth_domain", .type = ?[]const u8 }, + .{ .name = "user_agent", .type = ?[]const u8 }, + .{ .name = "block_private_networks", .type = bool }, + .{ .name = "block_cidrs", .type = ?[]const u8 }, }; fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { @@ -256,7 +259,7 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 { pub fn userAgent(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.user_agent, + inline .serve, .fetch, .mcp => |opts| opts.user_agent, .help, .version => null, }; } @@ -319,14 +322,14 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { pub fn blockPrivateNetworks(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.block_private_networks, + inline .serve, .fetch, .mcp => |opts| opts.block_private_networks, else => unreachable, }; } pub fn blockCidrs(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.block_cidrs, + inline .serve, .fetch, .mcp => |opts| opts.block_cidrs, else => unreachable, }; } From 156cf9b5a4e121659e91f3aa6fc4d328781bcc79 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 10 Apr 2026 17:48:35 +0300 Subject: [PATCH 18/43] `testing.zig`: init directly on `.serve` --- src/testing.zig | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/testing.zig b/src/testing.zig index bba6bcdd..1dac47dd 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -491,11 +491,9 @@ test "tests:beforeAll" { const test_allocator = @import("root").tracking_allocator; test_config = try Config.init(test_allocator, "test", .{ .serve = .{ - .common = .{ - .tls_verify_host = false, - .user_agent_suffix = "internal-tester", - .ws_max_concurrent = 50, - }, + .insecure_disable_tls_host_verification = true, + .user_agent_suffix = "internal-tester", + .ws_max_concurrent = 50, } }); test_app = try App.init(test_allocator, &test_config); From 74a518c56f5fee19d93794e93eea680c141b13a6 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Sat, 11 Apr 2026 12:50:36 +0300 Subject: [PATCH 19/43] `cli`: reintroduce command sniffing --- src/Config.zig | 1 - src/cli.zig | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/Config.zig b/src/Config.zig index 9f63cda4..b7ce94a0 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -653,7 +653,6 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { std.process.exit(1); } -// TODO: Fix mode sniff regression. pub fn parseArgs(allocator: Allocator) !Config { const exec_name, const command = try Commands.parse(allocator); return .init(allocator, exec_name, command); diff --git a/src/cli.zig b/src/cli.zig index 3041b9c6..1170d07f 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -279,6 +279,61 @@ pub fn Builder(comptime commands: anytype) type { } } + // Last resort, try sniffing. + const command_enum = try sniffCommand(cmd_str); + // "cmd_str" wasn't a command but an option. We can't reset args, but + // we can create a new one. Not great, but this fallback is temporary + // as we transition to this command mode approach. + args.deinit(); + args = try std.process.argsWithAllocator(allocator); + // Skip the `exec_name`. + _ = args.skip(); + + inline for (commands) |command| { + if (std.mem.eql(u8, @tagName(command_enum), command.name)) { + return .{ exec_name, try parseCommand(allocator, command, &args) }; + } + } + + unreachable; + } + + /// Try to sniff the command out of given option. + /// Only exists for legacy reasons; hence hardcoded. + fn sniffCommand(cmd_str: []const u8) error{UnknownCommand}!Enum { + if (std.mem.startsWith(u8, cmd_str, "--") == false) { + return .fetch; + } + + // Fetch heuristics. + inline for (.{ + "--dump", + "--strip", + "--with-base", + "--with_base", + "--with-frames", + "--with_frames", + }) |heuristic| { + if (std.mem.eql(u8, cmd_str, heuristic)) { + return .fetch; + } + } + + // Serve heuristics. + inline for (.{ + "--host", + "-h", + "-H", + "--port", + "-p", + "-P", + "--timeout", + }) |heuristic| { + if (std.mem.eql(u8, cmd_str, heuristic)) { + return .serve; + } + } + return error.UnknownCommand; } From 721b959dbf904a253f8e4d17ce482a5a38e68621 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Sat, 11 Apr 2026 14:34:35 +0300 Subject: [PATCH 20/43] `Config`: always return a valid mode for `--dump` --- src/Config.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index b7ce94a0..7e52c58c 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -80,11 +80,13 @@ fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { // Peek next argument. var peek_args = args.*; if (peek_args.next()) |next_arg| { - // Skip the argument we peek if successful. - defer _ = args.next(); - return std.meta.stringToEnum(DumpFormat, next_arg) orelse { - return error.UnknownDumpOption; + const mode = std.meta.stringToEnum(DumpFormat, next_arg) orelse { + return .html; }; + + // Skip the argument we peek if successful. + _ = args.next(); + return mode; } // Means we couldn't get something like `--dump html` but we do have From 5e0c046e960eb24664dc46fd5f7b11c97fb3fbb0 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 17 Apr 2026 16:48:32 +0300 Subject: [PATCH 21/43] rebase and remove commented out code --- src/Config.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 7e52c58c..286171d3 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -365,12 +365,6 @@ pub fn storageSqlitePath(self: *const Config) ?[:0]const u8 { else => unreachable, }; } - -//pub const Mcp = struct { -// common: Common = .{}, -// cdp_port: ?u16 = null, -//}; - pub const DumpFormat = enum { html, markdown, From 29d8e0c9b74e2fa0f48d456d34ecbb0cbfcba0ec Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 17 Apr 2026 17:32:05 +0300 Subject: [PATCH 22/43] `Config`: bring back `validateUserAgent` --- src/Config.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Config.zig b/src/Config.zig index 286171d3..1a5bc3ee 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -653,3 +653,15 @@ pub fn parseArgs(allocator: Allocator) !Config { const exec_name, const command = try Commands.parse(allocator); return .init(allocator, exec_name, command); } + +pub fn validateUserAgent(ua: []const u8) !void { + for (ua) |c| { + if (!std.ascii.isPrint(c)) { + return error.NonPrintable; + } + } + + if (std.ascii.indexOfIgnoreCase(ua, "mozilla") != null) { + return error.Reserved; + } +} From a3af914cda9774633f2d68cec65aadec9ae9269a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 17 Apr 2026 17:33:26 +0300 Subject: [PATCH 23/43] `Config`: adaptation to new cookie arguments --- src/Config.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 1a5bc3ee..ddffb052 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -74,6 +74,8 @@ const CommonOptions = .{ .{ .name = "user_agent", .type = ?[]const u8 }, .{ .name = "block_private_networks", .type = bool }, .{ .name = "block_cidrs", .type = ?[]const u8 }, + .{ .name = "cookie", .type = ?[]const u8 }, + .{ .name = "cookie_jar", .type = ?[]const u8 }, }; fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { @@ -275,14 +277,14 @@ pub fn httpCacheDir(self: *const Config) ?[]const u8 { pub fn cookieFile(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.cookie, + inline .serve, .fetch, .mcp => |opts| opts.cookie, else => null, }; } pub fn cookieJarFile(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .fetch, .mcp => |opts| opts.common.cookie_jar, + inline .fetch, .mcp => |opts| opts.cookie_jar, else => null, }; } From 012fe40bb5455aa5e3c5db8cdacb16ff602d2dcd Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 17 Apr 2026 17:33:54 +0300 Subject: [PATCH 24/43] `cli`: remove `aliases` and `shortcuts` --- src/Config.zig | 11 +++++------ src/cli.zig | 28 +++------------------------- 2 files changed, 8 insertions(+), 31 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index ddffb052..c5606135 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -101,8 +101,8 @@ const Commands = cli.Builder(.{ .{ .name = "serve", .options = .{ - .{ .name = "host", .shortcuts = .{ "h", "H" }, .type = []const u8, .default = "127.0.0.1" }, - .{ .name = "port", .shortcuts = .{ "p", "P" }, .type = u16, .default = 9222 }, + .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, + .{ .name = "port", .type = u16, .default = 9222 }, .{ .name = "advertise_host", .type = ?[]const u8 }, .{ .name = "timeout", .type = u31, .default = 10 }, .{ .name = "cdp_max_connections", .type = u16, .default = 16 }, @@ -112,11 +112,10 @@ const Commands = cli.Builder(.{ }, .{ .name = "fetch", - .aliases = .{ "f", "get" }, // This argument can be given out of order. .positional = .{ .name = "url", .type = ?[:0]const u8 }, .options = .{ - .{ .name = "dump", .shortcuts = .{ "d", "D" }, .type = ?DumpFormat, .validator = dumpValidator }, + .{ .name = "dump", .type = ?DumpFormat, .validator = dumpValidator }, .{ .name = "with_base", .type = bool }, .{ .name = "with_frames", .type = bool }, .{ .name = "strip", .type = dump.Opts.Strip, .default = dump.Opts.Strip{} }, @@ -134,8 +133,8 @@ const Commands = cli.Builder(.{ }, .shared_options = CommonOptions, }, - .{ .name = "version", .aliases = .{"v"}, .options = .{} }, - .{ .name = "help", .aliases = .{ "h", "?" }, .options = .{} }, + .{ .name = "version", .options = .{} }, + .{ .name = "help", .options = .{} }, }); pub const RunMode = Commands.Enum; diff --git a/src/cli.zig b/src/cli.zig index 1170d07f..66912387 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -263,19 +263,9 @@ pub fn Builder(comptime commands: anytype) type { const cmd_str: []const u8 = args.next() orelse return error.MissingCommand; inline for (commands) |command| { - // Command name together with it's aliases. - const with_aliases = blk: { - if (@hasField(@TypeOf(command), "aliases")) { - break :blk command.aliases ++ .{command.name}; - } - - break :blk .{command.name}; - }; - - inline for (with_aliases) |name| { - if (std.mem.eql(u8, cmd_str, name)) { - return .{ exec_name, try parseCommand(allocator, command, &args) }; - } + // Match a command. + if (std.mem.eql(u8, cmd_str, command.name)) { + return .{ exec_name, try parseCommand(allocator, command, &args) }; } } @@ -369,18 +359,6 @@ pub fn Builder(comptime commands: anytype) type { const match = std.mem.eql(u8, option_name, "--" ++ option.name) or std.mem.eql(u8, option_name, "--" ++ kebab_cased); - - // Name not matched; try shortcuts if provided. - if (!match) { - if (@hasField(@TypeOf(option), "shortcuts")) { - inline for (option.shortcuts) |shortcut| { - if (std.mem.eql(u8, option_name, "-" ++ shortcut)) { - break :blk true; - } - } - } - } - break :blk match; }; From 4a4e3643f5bfcab8b6340b01a95ca083872b8285 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 17 Apr 2026 17:42:32 +0300 Subject: [PATCH 25/43] `cli`: prefer `full` over `all` to enable everything --- src/cli.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 66912387..ee122974 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -440,8 +440,8 @@ pub fn Builder(comptime commands: anytype) type { const str = args.next() orelse return error.MissingArgument; - if (std.mem.eql(u8, str, "all")) { - // "all" sets all the fields of packed struct. + if (std.mem.eql(u8, str, "full")) { + // "full" sets all the fields of packed struct. const Int = _struct.backing_integer.?; @field(c, option.name) = @bitCast(@as(Int, std.math.maxInt(Int))); } else { From 1da11d8da80a376b1d9bfc9d4b361c24d0e5db86 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 17 Apr 2026 17:44:42 +0300 Subject: [PATCH 26/43] `Config`: revert to `--strip-mode` --- src/Config.zig | 8 ++++---- src/main.zig | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index c5606135..8f600e6d 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -118,7 +118,7 @@ const Commands = cli.Builder(.{ .{ .name = "dump", .type = ?DumpFormat, .validator = dumpValidator }, .{ .name = "with_base", .type = bool }, .{ .name = "with_frames", .type = bool }, - .{ .name = "strip", .type = dump.Opts.Strip, .default = dump.Opts.Strip{} }, + .{ .name = "strip_mode", .type = dump.Opts.Strip, .default = dump.Opts.Strip{} }, .{ .name = "wait_ms", .type = u32, .default = 5_000 }, .{ .name = "wait_until", .type = ?WaitUntil }, .{ .name = "wait_script", .type = ?[:0]const u8 }, @@ -552,12 +552,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'. \\ Defaults to no dump. \\ - \\--strip Comma separated list of tag groups to remove from dump - \\ the dump. e.g. --strip js,css + \\--strip-mode Comma separated list of tag groups to remove from dump + \\ the dump. e.g. --strip-mode js,css \\ - "js" script and link[as=script, rel=preload] \\ - "ui" includes img, picture, video, css and svg \\ - "css" includes style and link[rel=stylesheet] - \\ - "all" includes js, ui and css + \\ - "full" includes js, ui and css \\ \\--with-base Add a tag in dump. Defaults to false. \\ diff --git a/src/main.zig b/src/main.zig index b78a730e..9536001c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -127,7 +127,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { .wait_selector = opts.wait_selector, .dump_mode = opts.dump, .dump = .{ - .strip = opts.strip, + .strip = opts.strip_mode, .with_base = opts.with_base, .with_frames = opts.with_frames, }, From 7acbd541728b951e241ed1225a2572d42dbeac1a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 20 Apr 2026 13:32:58 +0300 Subject: [PATCH 27/43] `cli`: accept defaults for boolean Setting a boolean option, instead of setting it to `true`, prefers the opposite of `default`. The `default` is `false` if not provided. --- src/cli.zig | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index ee122974..6017793e 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -180,11 +180,9 @@ pub fn Builder(comptime commands: anytype) type { break :blk @as(*const anyopaque, @ptrCast(&@as(T, option.default))); }, .bool => { - if (has_default) { - @compileError("booleans are always `false` by default"); - } - // Booleans are always initalized false. - break :blk @as(*const anyopaque, @ptrCast(&@as(T, false))); + // Prefer `false` if no default. + const default = if (has_default) option.default else false; + break :blk @as(*const anyopaque, @ptrCast(&@as(T, default))); }, inline else => { if (!has_default) { @@ -482,7 +480,15 @@ pub fn Builder(comptime commands: anytype) type { @compileError("multiple option is not supported for booleans"); } - @field(c, option.name) = true; + const default = blk: { + if (@hasField(@TypeOf(option), "default")) { + break :blk option.default; + } + break :blk false; + }; + + // Set opposite of the default. + @field(c, option.name) = !default; }, else => {}, From baf97bb607bce71cf440b2b75586a285fd494f8c Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 20 Apr 2026 13:33:31 +0300 Subject: [PATCH 28/43] `cli`: remove shortcut bits in `sniffCommand` --- src/cli.zig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 6017793e..b1312763 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -310,11 +310,7 @@ pub fn Builder(comptime commands: anytype) type { // Serve heuristics. inline for (.{ "--host", - "-h", - "-H", "--port", - "-p", - "-P", "--timeout", }) |heuristic| { if (std.mem.eql(u8, cmd_str, heuristic)) { From 44849fdaf3c1aa97b8db6be51fcde666f0952ab0 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 20 Apr 2026 13:46:12 +0300 Subject: [PATCH 29/43] `cli`: `splitScalar` -> `tokenizeScalar` --- src/cli.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.zig b/src/cli.zig index b1312763..72b5b579 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -440,7 +440,7 @@ pub fn Builder(comptime commands: anytype) type { @field(c, option.name) = @bitCast(@as(Int, std.math.maxInt(Int))); } else { // Parse given args. - var it = std.mem.splitScalar(u8, str, ','); + var it = std.mem.tokenizeScalar(u8, str, ','); outer: while (it.next()) |part| { const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); From f389e1945f8dda3a72a4548630940009cad253a6 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 20 Apr 2026 13:51:04 +0300 Subject: [PATCH 30/43] rebase to main --- src/Config.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Config.zig b/src/Config.zig index 8f600e6d..785d490a 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -18,6 +18,7 @@ const std = @import("std"); const lp = @import("lightpanda"); +const log = lp.log; const builtin = @import("builtin"); const cli = @import("cli.zig"); @@ -27,7 +28,8 @@ const mcp = @import("mcp.zig"); const Storage = @import("storage/Storage.zig"); const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config; -const log = lp.log; +const Allocator = std.mem.Allocator; + pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; // max message size From 3619af2d4c0532aaddabbbbbd2810762e7a73f21 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 20 Apr 2026 14:49:21 +0300 Subject: [PATCH 31/43] `cli`: reintroduce old `--help` --- src/cli.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/cli.zig b/src/cli.zig index 72b5b579..fcfb3aef 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -269,6 +269,13 @@ pub fn Builder(comptime commands: anytype) type { // Last resort, try sniffing. const command_enum = try sniffCommand(cmd_str); + + // `help` takes no arguments; short-circuit so the sniffed flag + // isn't re-parsed as an unknown option. + if (command_enum == .help) { + return .{ exec_name, .{ .help = .{} } }; + } + // "cmd_str" wasn't a command but an option. We can't reset args, but // we can create a new one. Not great, but this fallback is temporary // as we transition to this command mode approach. @@ -318,6 +325,11 @@ pub fn Builder(comptime commands: anytype) type { } } + // Legacy `--help` flag maps to the `help` command. + if (std.mem.eql(u8, cmd_str, "--help")) { + return .help; + } + return error.UnknownCommand; } From 79319485ea17e3c7420cf9bf33e52caf2441b8b3 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 20 Apr 2026 14:52:05 +0300 Subject: [PATCH 32/43] `cli`: catch unknown arguments on options that take `packed struct` --- src/cli.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cli.zig b/src/cli.zig index fcfb3aef..0035e30c 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -464,6 +464,9 @@ pub fn Builder(comptime commands: anytype) type { continue :outer; } } + + // Not equal to any of the fields. + return error.UnknownArgument; } } }, From 77a494f1fb007cb52dcbb7bf4b15adb551e72f70 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 20 Apr 2026 14:52:18 +0300 Subject: [PATCH 33/43] `cli`: update doc-comment --- src/cli.zig | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 0035e30c..c16ba0e6 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -26,13 +26,14 @@ const Allocator = std.mem.Allocator; /// ## Command descriptor fields /// /// - `name: []const u8` — canonical command name on the command line. -/// - `options: tuple` — tuple of option descriptors (see below). Use `.{}` for none. -/// - `aliases: tuple` (optional) — alternative names for the command. +/// - `options: tuple` — tuple of option descriptors (see below). Use `.{}` +/// for none. /// - `shared_options: tuple` (optional) — extra options merged into this /// command. Useful for common flags shared across commands. /// - `positional: struct` (optional) — a single positional argument with /// `.name` and `.type`. Type must be an optional pointer-to-u8 slice -/// (e.g. `?[:0]const u8`). Positionals can appear anywhere in argv. +/// (e.g. `?[:0]const u8`). Positionals can appear anywhere in argv and +/// must be provided; a missing positional returns `error.MissingArgument`. /// /// ## Option descriptor fields /// @@ -41,38 +42,41 @@ const Allocator = std.mem.Allocator; /// - `type` — the Zig type of the parsed value (see supported types below). /// - `default` (optional) — compile-time default when the flag is absent. /// Rules vary by type; see the defaults section below. -/// - `shortcuts: tuple` (optional) — single-character short flags. Each -/// shortcut is matched as `-X` on the command line. /// - `multiple: bool` (optional) — when `true`, the field becomes a -/// `std.ArrayList(type)` and each occurrence appends. +/// `std.ArrayList(type)` and each occurrence appends. Not supported for +/// `bool` or packed-struct options. /// - `validator: fn` (optional) — custom parse function that replaces the /// built-in type switch. See the validator section below. /// /// ## Supported types and their defaults /// -/// - `bool` — presence sets `true`; always defaults to `false`. -/// Specifying `default` is a compile error. `?bool` is not allowed. +/// - `bool` — presence flips the field to the opposite of its `default` +/// (so a flag with `default = true` acts as a disable switch). Defaults +/// to `false` when no `default` is given. `?bool` is not allowed. /// - Integers (`u8`, `u16`, `u31`, `usize`, etc.) — parsed with /// `std.fmt.parseInt`. Requires `default` unless wrapped in `?`. -/// - `[]const u8`, `[:0]const u8` (and mutable variants) — string slices, +/// - `[]const u8`, `[:0]const u8` (and mutable variants) — string slices /// duped from argv. Sentinel is preserved. Requires `default` unless `?`. -/// - Enums — parsed via `std.meta.stringToEnum`. Requires `default` unless `?`. +/// - Enums — parsed via `std.meta.stringToEnum`. Returns +/// `error.UnknownArgument` on a bad value. Requires `default` unless `?`. /// - Packed structs of `bool` fields — parsed from a comma-separated list -/// (e.g. `--strip js,css`). The literal `"all"` sets every field. -/// Requires `default`. +/// (e.g. `--strip js,css`). The literal `"full"` sets every field. +/// Unknown names return `error.UnknownArgument`. Requires `default`. +/// `multiple` is not supported. /// - Optional types default to `null` when `default` is omitted. /// /// ## Validators /// /// A `validator` is a custom parse function that takes over argument -/// consumption for an option. The expected signature depends on whether -/// `multiple` is set: +/// consumption for an option. Its signature depends on whether `multiple` +/// is set: /// /// - Single: `fn (Allocator, *ArgIterator) !T` — returns the parsed value. /// - Multiple: `fn (Allocator, *ArgIterator, *std.ArrayList(T)) !void` — /// appends directly into the list. /// /// When a validator is present, the built-in type switch is skipped entirely. +/// The validator owns advancing the iterator and is free to peek ahead. /// /// ## Example /// @@ -93,10 +97,9 @@ const Allocator = std.mem.Allocator; /// const Cli = cli.Builder(.{ /// .{ /// .name = "serve", -/// .aliases = .{"s"}, /// .options = .{ -/// .{ .name = "host", .shortcuts = .{"h"}, .type = []const u8, .default = "127.0.0.1" }, -/// .{ .name = "port", .shortcuts = .{"p"}, .type = u16, .default = 9222 }, +/// .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, +/// .{ .name = "port", .type = u16, .default = 9222 }, /// }, /// .shared_options = CommonOptions, /// }, @@ -105,14 +108,14 @@ const Allocator = std.mem.Allocator; /// .positional = .{ .name = "url", .type = ?[:0]const u8 }, /// .options = .{ /// .{ .name = "dump", .type = ?DumpFormat, .validator = dumpValidator }, -/// .{ .name = "strip", .type = StripMode, .default = .{} }, +/// .{ .name = "strip_mode", .type = StripMode, .default = .{} }, /// .{ .name = "wait_until", .type = ?WaitUntil }, /// .{ .name = "extra_header", .type = []const u8, .multiple = true }, /// }, /// .shared_options = CommonOptions, /// }, /// .{ .name = "version", .options = .{} }, -/// .{ .name = "help", .aliases = .{ "h", "?" }, .options = .{} }, +/// .{ .name = "help", .options = .{} }, /// }); /// /// const _, const cmd = try Cli.parse(arena); From 59afb9e773fe71e650611e3c2575301d44246fa7 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 20 Apr 2026 15:04:32 +0300 Subject: [PATCH 34/43] `cli`: `--strip` -> `--strip-mode` in sniff and doc-comment --- src/cli.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index c16ba0e6..e50fb96a 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -60,7 +60,7 @@ const Allocator = std.mem.Allocator; /// - Enums — parsed via `std.meta.stringToEnum`. Returns /// `error.UnknownArgument` on a bad value. Requires `default` unless `?`. /// - Packed structs of `bool` fields — parsed from a comma-separated list -/// (e.g. `--strip js,css`). The literal `"full"` sets every field. +/// (e.g. `--strip-mode js,css`). The literal `"full"` sets every field. /// Unknown names return `error.UnknownArgument`. Requires `default`. /// `multiple` is not supported. /// - Optional types default to `null` when `default` is omitted. @@ -306,7 +306,8 @@ pub fn Builder(comptime commands: anytype) type { // Fetch heuristics. inline for (.{ "--dump", - "--strip", + "--strip-mode", + "--strip_mode", "--with-base", "--with_base", "--with-frames", From 10228752344de7f953a1262b62e8add903e4b266 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 22 Apr 2026 16:02:59 +0300 Subject: [PATCH 35/43] `cli`: improve failure messages --- src/cli.zig | 63 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index e50fb96a..1d6b9338 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -18,6 +18,8 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const lp = @import("lightpanda"); +const log = lp.log; /// Comptime CLI builder that generates a tagged union parser from a /// declarative command recipe. Each command becomes a union variant whose @@ -355,23 +357,20 @@ pub fn Builder(comptime commands: anytype) type { }; iter_args: while (args.next()) |option_name| { inline for (options) |option| { - // Match an option. - const match = blk: { - // We allow both `--my-option` and `--my_option` variants; - // assuming given `option` struct prefer snake_case for `name`. - const kebab_cased = comptime casing: { - var output: [option.name.len]u8 = undefined; - @memcpy(&output, option.name); - std.mem.replaceScalar(u8, &output, '_', '-'); - break :casing output; - }; - - const match = - std.mem.eql(u8, option_name, "--" ++ option.name) or - std.mem.eql(u8, option_name, "--" ++ kebab_cased); - break :blk match; + // We allow both `--my-option` and `--my_option` variants; + // assuming given `option` struct prefer snake_case for `name`. + const kebab_cased = comptime casing: { + var output: [option.name.len]u8 = undefined; + @memcpy(&output, option.name); + std.mem.replaceScalar(u8, &output, '_', '-'); + break :casing "--" ++ output; }; + // Match an option. + const match = + std.mem.eql(u8, option_name, "--" ++ option.name) or + std.mem.eql(u8, option_name, kebab_cased); + if (match) { const T = option.type; const option_info = blk: { @@ -399,7 +398,15 @@ pub fn Builder(comptime commands: anytype) type { switch (option_info) { .int => |int| { const Int = std.meta.Int(int.signedness, int.bits); - const v = try std.fmt.parseInt(Int, args.next() orelse return error.MissingArgument, 10); + + const str = args.next() orelse return error.MissingArgument; + const v = std.fmt.parseInt(Int, str, 10) catch |err| { + switch (err) { + error.Overflow => log.fatal(.app, "range overflow", .{ .arg = kebab_cased, .value = str }), + error.InvalidCharacter => log.fatal(.app, "invalid character", .{ .arg = kebab_cased, .value = str }), + } + continue :iter_args; + }; if (is_multiple) { // Push to ArrayList. @@ -461,7 +468,10 @@ pub fn Builder(comptime commands: anytype) type { const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); inline for (_struct.fields) |f| { - std.debug.assert(f.type == bool); + lp.assert(f.type == bool, "all fields of packed struct must be boolean", .{ + .option = option.name, + .field = f.name, + }); if (std.mem.eql(u8, trimmed, @as([]const u8, f.name))) { @field(@field(c, option.name), f.name) = true; @@ -469,8 +479,8 @@ pub fn Builder(comptime commands: anytype) type { } } - // Not equal to any of the fields. - return error.UnknownArgument; + // Invalid option choice. + log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = trimmed }); } } }, @@ -480,8 +490,10 @@ pub fn Builder(comptime commands: anytype) type { inline else => T, }; - const v = std.meta.stringToEnum(E, args.next() orelse return error.MissingArgument) orelse { - return error.UnknownArgument; + const str = args.next() orelse return error.MissingArgument; + const v = std.meta.stringToEnum(E, str) orelse { + log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = str }); + continue :iter_args; }; if (is_multiple) { @@ -514,6 +526,12 @@ pub fn Builder(comptime commands: anytype) type { } } + // Encountered an option we don't know of. + if (std.mem.startsWith(u8, option_name, "--")) { + log.fatal(.app, "unknown argument", .{ .mode = command.name, .arg = option_name }); + return error.UnknownOption; + } + // Parse positional arg if provided; can be given out of order: // // lightpanda fetch --wait-ms 2_000 "https://lightpanda.io" --dump "html" @@ -556,9 +574,6 @@ pub fn Builder(comptime commands: anytype) type { }, inline else => @compileError("not supported"), } - } else { - // An option we don't know of. - return error.UnknownOption; } } From 7035317e3e41b616ecd09d54e6ad3cd1ebea4d1a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 22 Apr 2026 16:14:28 +0300 Subject: [PATCH 36/43] `Config`: rebase to main --- src/Config.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 785d490a..15a64a6c 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -78,6 +78,8 @@ const CommonOptions = .{ .{ .name = "block_cidrs", .type = ?[]const u8 }, .{ .name = "cookie", .type = ?[]const u8 }, .{ .name = "cookie_jar", .type = ?[]const u8 }, + .{ .name = "storage_engine", .type = ?Storage.EngineType }, + .{ .name = "storage_sqlite_path", .type = ?[:0]const u8 }, }; fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { @@ -357,14 +359,14 @@ pub fn maxPendingConnections(self: *const Config) u31 { pub fn storageEngine(self: *const Config) ?Storage.EngineType { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.storage_engine, + inline .serve, .fetch, .mcp => |opts| opts.storage_engine, else => unreachable, -} }; +} pub fn storageSqlitePath(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.storage_sqlite_path, + inline .serve, .fetch, .mcp => |opts| opts.storage_sqlite_path, else => unreachable, }; } From fa302a1db101d2f2e64891c295bbf87812218e83 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 22 Apr 2026 16:37:37 +0300 Subject: [PATCH 37/43] `cli`: report failure on `lightpanda serve serve` case --- src/cli.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cli.zig b/src/cli.zig index 1d6b9338..8506839a 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -574,6 +574,9 @@ pub fn Builder(comptime commands: anytype) type { }, inline else => @compileError("not supported"), } + } else { + log.fatal(.app, "unknown argument", .{ .mode = command.name, .arg = option_name }); + return error.UnknownOption; } } From 338e53460ac13cde704a96d493c46a6e53f470dc Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 23 Apr 2026 09:14:43 +0800 Subject: [PATCH 38/43] Add setter for document.body Fixes: https://github.com/lightpanda-io/browser/issues/2213 --- src/browser/tests/document/set_body.html | 107 ++++++++++++++++++ .../tests/document/set_body/complex.html | 5 + .../tests/document/set_body/empty.html | 5 + .../tests/document/set_body/frameset.html | 7 ++ .../tests/document/set_body/no_body.html | 9 ++ .../tests/document/set_body/simple.html | 5 + src/browser/webapi/HTMLDocument.zig | 28 ++++- 7 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 src/browser/tests/document/set_body.html create mode 100644 src/browser/tests/document/set_body/complex.html create mode 100644 src/browser/tests/document/set_body/empty.html create mode 100644 src/browser/tests/document/set_body/frameset.html create mode 100644 src/browser/tests/document/set_body/no_body.html create mode 100644 src/browser/tests/document/set_body/simple.html diff --git a/src/browser/tests/document/set_body.html b/src/browser/tests/document/set_body.html new file mode 100644 index 00000000..09e26847 --- /dev/null +++ b/src/browser/tests/document/set_body.html @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/document/set_body/complex.html b/src/browser/tests/document/set_body/complex.html new file mode 100644 index 00000000..0f3823ec --- /dev/null +++ b/src/browser/tests/document/set_body/complex.html @@ -0,0 +1,5 @@ + +
original
+ diff --git a/src/browser/tests/document/set_body/empty.html b/src/browser/tests/document/set_body/empty.html new file mode 100644 index 00000000..f946a756 --- /dev/null +++ b/src/browser/tests/document/set_body/empty.html @@ -0,0 +1,5 @@ + +
to be wiped
+ diff --git a/src/browser/tests/document/set_body/frameset.html b/src/browser/tests/document/set_body/frameset.html new file mode 100644 index 00000000..4e694801 --- /dev/null +++ b/src/browser/tests/document/set_body/frameset.html @@ -0,0 +1,7 @@ + +
original
+ diff --git a/src/browser/tests/document/set_body/no_body.html b/src/browser/tests/document/set_body/no_body.html new file mode 100644 index 00000000..a50bddcd --- /dev/null +++ b/src/browser/tests/document/set_body/no_body.html @@ -0,0 +1,9 @@ + + +no_body + + + diff --git a/src/browser/tests/document/set_body/simple.html b/src/browser/tests/document/set_body/simple.html new file mode 100644 index 00000000..7bdc2db4 --- /dev/null +++ b/src/browser/tests/document/set_body/simple.html @@ -0,0 +1,5 @@ + +
original
+ diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index 6487fc2b..e6904006 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -57,8 +57,30 @@ pub fn getHead(self: *HTMLDocument) ?*Element.Html.Head { } pub fn getBody(self: *HTMLDocument) ?*Element.Html.Body { - const doc_el = self._proto.getDocumentElement() orelse return null; - var child = doc_el.asNode().firstChild(); + const document_element = self._proto.getDocumentElement() orelse return null; + return findBodyForDoc(document_element); +} + +pub fn setBody(self: *HTMLDocument, html: []const u8, frame: *Frame) !void { + const document_element = self._proto.getDocumentElement() orelse return error.HierarchyError; + + // Build a fresh holding the parsed HTML as its children. Fragment + // parsing strips any // wrappers the author included. + const new_body_node = try frame.createElementNS(.html, "body", null); + if (html.len > 0) { + try frame.parseHtmlAsChildren(new_body_node, html); + } + + const document_node = document_element.asNode(); + if (findBodyForDoc(document_element)) |current| { + _ = try document_node.replaceChild(new_body_node, current.asNode(), frame); + } else { + _ = try document_node.appendChild(new_body_node, frame); + } +} + +fn findBodyForDoc(document_element: *Element) ?*Element.Html.Body { + var child = document_element.asNode().firstChild(); while (child) |node| { if (node.is(Element.Html.Body)) |body| { return body; @@ -276,7 +298,7 @@ pub const JsApi = struct { pub const dir = bridge.accessor(HTMLDocument.getDir, HTMLDocument.setDir, .{}); pub const head = bridge.accessor(HTMLDocument.getHead, null, .{}); - pub const body = bridge.accessor(HTMLDocument.getBody, null, .{}); + pub const body = bridge.accessor(HTMLDocument.getBody, HTMLDocument.setBody, .{ .dom_exception = true }); pub const lang = bridge.accessor(HTMLDocument.getLang, HTMLDocument.setLang, .{}); pub const title = bridge.accessor(HTMLDocument.getTitle, HTMLDocument.setTitle, .{}); pub const images = bridge.accessor(HTMLDocument.getImages, null, .{}); From 73320e163d4359121840fb77477c40d79e8f9ac3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 23 Apr 2026 09:18:48 +0800 Subject: [PATCH 39/43] Add placeholder handlers for Audit enable/disable CDP methods Might help with: https://github.com/lightpanda-io/browser/issues/2177 I say "might" because there are a 2 more methods in Audit which I haven't implemented. This is just the most basic placeholder for now. --- src/cdp/CDP.zig | 1 + src/cdp/domains/audit.zig | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/cdp/domains/audit.zig diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index ec23e658..3ec21c63 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -223,6 +223,7 @@ fn dispatchCommand(command: *Command, method: []const u8) !void { 5 => switch (@as(u40, @bitCast(domain[0..5].*))) { asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command), asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command), + asUint(u40, "Audit") => return @import("domains/audit.zig").processMessage(command), else => {}, }, 6 => switch (@as(u48, @bitCast(domain[0..6].*))) { diff --git a/src/cdp/domains/audit.zig b/src/cdp/domains/audit.zig new file mode 100644 index 00000000..c51bd609 --- /dev/null +++ b/src/cdp/domains/audit.zig @@ -0,0 +1,39 @@ +// Copyright (C) 2023-2026 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 CDP = @import("../CDP.zig"); + +pub fn processMessage(cmd: *CDP.Command) !void { + const action = std.meta.stringToEnum(enum { + enable, + disable, + }, cmd.input.action) orelse return error.UnknownMethod; + + switch (action) { + .enable => return enable(cmd), + .disable => return disable(cmd), + } +} +fn enable(cmd: *CDP.Command) !void { + return cmd.sendResult(null, .{}); +} + +fn disable(cmd: *CDP.Command) !void { + return cmd.sendResult(null, .{}); +} From e68dd2b284f476acc3660777cde8afade2d7d5db Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 23 Apr 2026 11:23:37 +0800 Subject: [PATCH 40/43] Clamp insert index Because we don't fully process CSS, indexes that code expect might be out of bounds. The specific case comes from https://github.com/lightpanda-io/browser/issues/2214 which uses fullcalender library. This library uses a @font-face which we do not process and thus end up with incorrect indexes. As a quick workaround aligned with previous fixes for these types of cases (and what the original issue recommended), we simply clamp the index. --- src/browser/tests/css/stylesheet.html | 22 ++++++++++++++++++++++ src/browser/webapi/css/CSSStyleSheet.zig | 14 ++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/browser/tests/css/stylesheet.html b/src/browser/tests/css/stylesheet.html index e242f99d..0dba8165 100644 --- a/src/browser/tests/css/stylesheet.html +++ b/src/browser/tests/css/stylesheet.html @@ -498,6 +498,28 @@ } + + + + + +