diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml index cd8b160423..4ddf768391 100644 --- a/.github/workflows/zig-test.yml +++ b/.github/workflows/zig-test.yml @@ -13,6 +13,8 @@ on: paths: - ".github/**" - "src/**" + - "include/**" + - "examples/**" - "build.zig" - "build.zig.zon" @@ -27,6 +29,8 @@ on: paths: - ".github/**" - "src/**" + - "include/**" + - "examples/**" - "build.zig" - "build.zig.zon" @@ -73,6 +77,38 @@ jobs: - name: zig build test run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test + c-library: + name: c library + needs: zig-fmt + + runs-on: ubuntu-latest + timeout-minutes: 15 + + if: github.event.pull_request.draft == false + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - uses: ./.github/actions/install + + - name: zig build test-lib + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a test-lib + + - name: zig build lib + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a lib + + - name: build the C example against the installed library + run: | + cc examples/c/fetch.c $(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) \ + -Wl,-rpath,$PWD/zig-out/lib -o fetch-example + + - name: run the example and verify its output + run: | + ./fetch-example https://example.com > out.html + grep -q "Example Domain" out.html + zig-test-release: name: zig test needs: zig-fmt diff --git a/Makefile b/Makefile index dadfa59fa3..0a64f31412 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,8 @@ else $(error "Unhandled kernel: $(kernel)") endif +LIB_EXT := $(if $(filter macos,$(OS)),dylib,so) + # Prebuilt V8 # ----------- @@ -77,7 +79,7 @@ help: # $(ZIG) commands # ------------ -.PHONY: build build-v8-snapshot build-dev download-v8 run run-release test bench data end2end clean +.PHONY: build build-v8-snapshot build-dev download-v8 lib lib-example test-lib run run-release test bench data end2end clean ## Download the prebuilt V8 libraries (skips the 10+ min source build) download-v8: @@ -115,6 +117,26 @@ build-dev: @$(ZIG) build $(ZIGFLAGS) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @printf "\033[33mBuild OK\033[0m\n" +## Run the C ABI unit tests +test-lib: + @$(ZIG) build $(ZIGFLAGS) test-lib -freference-trace + +# -Ddev_fast=false: dev_fast links V8 shared, but liblightpanda.so must stay +# self-contained (no DT_NEEDED on libc_v8.so), so lib embeds the static V8. +## Build the C shared library (zig-out/lib + zig-out/include) +lib: + @printf "\033[36mBuilding C shared library...\033[0m\n" + @$(ZIG) build lib -Ddev_fast=false || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) + @printf "\033[33mBuild OK: zig-out/lib/liblightpanda.$(LIB_EXT)\033[0m\n" + +## Link and run the C example against the shared library (needs network) +lib-example: lib + @mkdir -p zig-out/bin + @cc examples/c/fetch.c $$(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) \ + -Wl,-rpath,$(BC)zig-out/lib -o zig-out/bin/fetch-example + @./zig-out/bin/fetch-example https://example.com > /dev/null \ + && printf "\033[33mExample OK\033[0m\n" + ## Run the server in release mode run: build @printf "\033[36mRunning...\033[0m\n" diff --git a/build.zig b/build.zig index ec613c9979..a99e1e7bc0 100644 --- a/build.zig +++ b/build.zig @@ -22,6 +22,12 @@ const builtin = @import("builtin"); const lightpanda_version = std.SemanticVersion.parse(@import("build.zig.zon").version) catch unreachable; const min_zig_version = std.SemanticVersion.parse(@import("build.zig.zon").minimum_zig_version) catch unreachable; +// A host linking its own curl/zlib/... must not bind to liblightpanda's +// bundled copies. ELF hides them via src/lightpanda.map; Mach-O has no +// version-script equivalent, so they are hidden at compile time. +// Unconditional: the executable exports nothing either way. +const hide_symbols = "-fvisibility=hidden"; + const Build = blk: { if (builtin.zig_version.order(min_zig_version) == .lt) { @compileError(std.fmt.comptimePrint( @@ -201,6 +207,124 @@ pub fn build(b: *Build) !void { const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_tests.step); } + + { + // c api + const c_api_module = createCApiModule(b, lightpanda_module); + + const c_api_check = b.addLibrary(.{ + .name = "c_api_check", + .root_module = c_api_module, + }); + check.dependOn(&c_api_check.step); + + // A shared V8 would leave liblightpanda.so with a DT_NEEDED on + // libc_v8.so; the artifact must stay self-contained. + const lib_step = b.step("lib", "Build the C shared library"); + if (!shared_v8) { + const shared_lib = b.addLibrary(.{ + .name = "lightpanda", + .linkage = .dynamic, + .use_llvm = true, + .root_module = c_api_module, + }); + shared_lib.version_script = b.path("src/lightpanda.map"); + shared_lib.linker_allow_shlib_undefined = false; + const install_so = b.addInstallArtifact(shared_lib, .{}); + // Gate the install on the export check so an installed library + // is always a checked one (see hide_symbols for why). + const Query = struct { nm: []const u8, awk: []const u8 }; + const leak_query: ?Query = switch (target.result.os.tag) { + // The version script keeps everything but lp_* local, so + // anything else in the dynamic table is a leak. + .linux => .{ + .nm = "nm -D", + .awk = "$2 == \"T\" && $3 !~ /^lp_/ { print $3 }", + }, + // V8's own C++ symbols stay exported on Mach-O, so only the + // bundled C libraries — the ones include/lightpanda.h + // promises absent — can be asserted. + .macos => .{ + .nm = "nm -gU", + .awk = "$2 == \"T\" && $3 ~ /^_(AES|ASN1|BIO|Brotli|EVP|OPENSSL|RSA|SSL|X509|adler32|crc32|curl|deflate|inflate|nghttp2|sqlite3)/ { print $3 }", + }, + else => null, + }; + if (leak_query) |query| { + const export_check = b.addSystemCommand(&.{ + "sh", "-ec", + // Two statements, not `test -z "$(nm ... | awk ...)"`: there + // a failing nm yields no output and the check passes. The + // bare assignment lets -e see nm's status. + b.fmt( + \\symbols=$({s} "$0") + \\leaked=$(printf '%s\n' "$symbols" | awk '{s}') + \\test -z "$leaked" || + \\ {{ echo "liblightpanda exports bundled dependency symbols:" >&2 + \\ printf '%s\n' "$leaked" | head -20 >&2; exit 1; }} + \\: > "$1" + , .{ query.nm, query.awk }), + }); + export_check.addFileArg(shared_lib.getEmittedBin()); + _ = export_check.addOutputFileArg("export-check-ok"); + install_so.step.dependOn(&export_check.step); + } + lib_step.dependOn(&install_so.step); + lib_step.dependOn(&b.addInstallHeaderFile(b.path("include/lightpanda.h"), "lightpanda.h").step); + const shared_pc = pkgConfigFile(b, version_string); + lib_step.dependOn(&b.addInstallLibFile(shared_pc, "pkgconfig/lightpanda.pc").step); + } else { + lib_step.dependOn(&b.addFail("lib needs V8 linked statically: pass -Ddev_fast=false and a libc_v8.a (or no) -Dprebuilt_v8_path").step); + } + + // Own binary: the two test suites must not share one V8 platform. + // The ABI-sync test compares c_api.zig's mirrors against the header + // itself. Test-only import: the .so module must not depend on the + // header, or every header edit relinks it. + const lib_tests_module = createCApiModule(b, lightpanda_module); + const header_translate_c = b.addTranslateC(.{ + .root_source_file = b.path("include/lightpanda.h"), + .target = target, + .optimize = optimize, + }); + lib_tests_module.addImport("lightpanda_h", header_translate_c.createModule()); + const lib_tests = b.addTest(.{ + .root_module = lib_tests_module, + .use_llvm = true, + .test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple }, + }); + const test_lib_step = b.step("test-lib", "Run the C ABI unit tests"); + test_lib_step.dependOn(&b.addRunArtifact(lib_tests).step); + } +} + +fn createCApiModule(b: *Build, lightpanda: *Build.Module) *Build.Module { + const mod = b.createModule(.{ + .root_source_file = b.path("src/c_api.zig"), + .target = lightpanda.resolved_target.?, + .optimize = lightpanda.optimize.?, + .link_libc = true, + .link_libcpp = true, + .sanitize_c = lightpanda.sanitize_c, + .sanitize_thread = lightpanda.sanitize_thread, + }); + mod.addImport("lightpanda", lightpanda); + return mod; +} + +fn pkgConfigFile(b: *Build, version: []const u8) Build.LazyPath { + return b.addWriteFiles().add("lightpanda.pc", b.fmt( + \\prefix=${{pcfiledir}}/../.. + \\libdir=${{prefix}}/lib + \\includedir=${{prefix}}/include + \\ + \\Name: lightpanda + \\Description: Lightpanda headless browser C library + \\Version: {s} + \\Cflags: -I${{includedir}} + \\Libs: -L${{libdir}} -llightpanda + \\ + , .{version})); } const ExeConfig = struct { @@ -370,8 +494,13 @@ fn linkSqlite(b: *Build, mod: *Build.Module, enable_csan: ?std.zig.SanitizeC, is const lib = dep.artifact("sqlite3"); lib.root_module.sanitize_c = enable_csan; lib.root_module.sanitize_thread = is_tsan; + lib.root_module.pic = true; const macros = [_]struct { []const u8, []const u8 }{ + // The amalgamation is one translation unit, so everything not static is + // tagged SQLITE_API; redefining it hides the lot. `-fvisibility=hidden` + // is not reachable here — the sources belong to the dependency. + .{ "SQLITE_API", "__attribute__((visibility(\"hidden\")))" }, .{ "SQLITE_DEFAULT_FILE_PERMISSIONS", "0600" }, .{ "SQLITE_DEFAULT_MEMSTATUS", "0" }, .{ "SQLITE_DEFAULT_WAL_SYNCHRONOUS", "1" }, @@ -460,6 +589,7 @@ fn cLibModule(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opt .optimize = optimize, .link_libc = true, .sanitize_thread = is_tsan, + .pic = true, }); } @@ -472,6 +602,7 @@ fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opti mod.addCSourceFiles(.{ .root = dep.path(""), .flags = &.{ + hide_symbols, "-DHAVE_SYS_TYPES_H", "-DHAVE_STDINT_H", "-DHAVE_STDDEF_H", @@ -502,6 +633,7 @@ fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Op brotlicmn.installHeadersDirectory(dep.path("c/include/brotli"), "brotli", .{}); mod.addCSourceFiles(.{ .root = dep.path("c/common"), + .flags = &.{hide_symbols}, .files = &.{ "transform.c", "shared_dictionary.c", "platform.c", "dictionary.c", "context.c", "constants.c", @@ -509,6 +641,7 @@ fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Op }); mod.addCSourceFiles(.{ .root = dep.path("c/dec"), + .flags = &.{hide_symbols}, .files = &.{ "bit_reader.c", "decode.c", "huffman.c", "prefix.c", "state.c", "static_init.c", @@ -516,6 +649,7 @@ fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Op }); mod.addCSourceFiles(.{ .root = dep.path("c/enc"), + .flags = &.{hide_symbols}, .files = &.{ "backward_references.c", "backward_references_hq.c", "bit_cost.c", "block_splitter.c", "brotli_bit_stream.c", "cluster.c", @@ -536,6 +670,7 @@ fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin .target = target, .optimize = optimize, .force_pic = true, + .hidden_visibility = true, }); const ssl = dep.artifact("ssl"); @@ -569,6 +704,7 @@ fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.O mod.addCSourceFiles(.{ .root = dep.path("lib"), .flags = &.{ + hide_symbols, "-DNGHTTP2_STATICLIB", "-DHAVE_TIME_H", "-DHAVE_ARPA_INET_H", @@ -847,6 +983,7 @@ fn buildCurl( mod.addCSourceFiles(.{ .root = dep.path("lib"), .flags = &.{ + hide_symbols, "-D_GNU_SOURCE", "-DHAVE_CONFIG_H", "-DCURL_STATICLIB", diff --git a/build.zig.zon b/build.zig.zon index 6d14d08a2f..503418d0aa 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -23,8 +23,8 @@ .hash = "N-V-__8AAMHpvwChC6orFCDB8MeiumT6OlfmKfmrWlak7Int", }, .@"boringssl-zig" = .{ - .url = "git+https://github.com/lightpanda-io/boringssl-zig.git#f07cc58fa4a051eb0985287e691db5dbb0e7e76f", - .hash = "boringssl-0.1.0-VtJeWd1OAAAQ5AuNOZGP5a_5ZyNn5BfeE-jiKqEm2gsE", + .url = "git+https://github.com/lightpanda-io/boringssl-zig.git#0c108496c65e45526552d416621b28a105ff25d4", + .hash = "boringssl-0.1.0-VtJeWTZSAAANq2AyGl10FISzYmg8rVXlc8Hlc75QWdJs", }, // .@"boringssl-zig" = .{ .path = "../boringssl-zig" }, .curl = .{ diff --git a/examples/c/fetch.c b/examples/c/fetch.c new file mode 100644 index 0000000000..41a7e85285 --- /dev/null +++ b/examples/c/fetch.c @@ -0,0 +1,41 @@ +/* Fetch a page and print it as markdown. + * + * Build from the repo root after `make lib`, with the link line + * documented in include/lightpanda.h. Run: + * ./fetch https://example.com + */ + +#include +#include +#include + +int main(int argc, char **argv) { + if (argc < 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + lp_browser *browser = NULL; + lp_status status = lp_init(NULL, &browser); + if (status != LP_OK) { + fprintf(stderr, "lp_init failed: %d\n", status); + return 1; + } + + lp_fetch_opts opts = {0}; + opts.format = LP_FORMAT_MARKDOWN; + + lp_result result = {0}; + status = lp_fetch(browser, argv[1], strlen(argv[1]), &opts, &result); + if (status != LP_OK) { + fprintf(stderr, "lp_fetch failed: %d\n", status); + lp_shutdown(browser); + return 1; + } + + /* result.text is library-owned: valid until the next lp_fetch on this + * browser or lp_shutdown. */ + fwrite(result.text, 1, result.len, stdout); + lp_shutdown(browser); + return 0; +} diff --git a/examples/c/tools.c b/examples/c/tools.c new file mode 100644 index 0000000000..669175c29e --- /dev/null +++ b/examples/c/tools.c @@ -0,0 +1,60 @@ +/* Drive the browser through the tool surface: navigate, then extract the + * page title and links via a selector schema. + * + * Build from the repo root after `make lib`, with the link line + * documented in include/lightpanda.h. Run: + * ./tools https://example.com + */ + +#include +#include +#include + +static lp_status call(lp_session *session, const char *tool, const char *args) { + lp_result result = {0}; + lp_status status = lp_call(session, tool, strlen(tool), args, args ? strlen(args) : 0, &result); + if (status != LP_OK) { + size_t err_len = 0; + const char *err = lp_last_error(session, &err_len); + fprintf(stderr, "%s failed: %d (%.*s)\n", tool, status, (int)err_len, err ? err : ""); + return status; + } + printf("--- %s%s ---\n%.*s\n", tool, result.is_error ? " (page error)" : "", (int)result.len, result.text); + return LP_OK; +} + +int main(int argc, char **argv) { + const char *url = argc > 1 ? argv[1] : "https://example.com"; + + lp_browser *browser = NULL; + if (lp_init(NULL, &browser) != LP_OK) { + fprintf(stderr, "lp_init failed\n"); + return 1; + } + + lp_session *session = NULL; + if (lp_session_new(browser, &session) != LP_OK) { + fprintf(stderr, "lp_session_new failed\n"); + lp_shutdown(browser); + return 1; + } + + char args[1024]; + snprintf(args, sizeof args, "{\"url\":\"%s\"}", url); + lp_status status = call(session, "goto", args); + + /* extract's "schema" argument is a string holding a JSON object literal + * that maps output fields to CSS-selector specs (not a JSON Schema). */ + if (status == LP_OK) + status = call(session, "extract", + "{\"schema\":" + "\"{\\\"title\\\": \\\"title\\\"," + " \\\"links\\\": [{\\\"selector\\\": \\\"a\\\", \\\"attr\\\": \\\"href\\\"}]}\"" + "}"); + if (status == LP_OK) + status = call(session, "getUrl", NULL); + + lp_session_close(session); + lp_shutdown(browser); + return status == LP_OK ? 0 : 1; +} diff --git a/include/lightpanda.h b/include/lightpanda.h new file mode 100644 index 0000000000..a03c7942c9 --- /dev/null +++ b/include/lightpanda.h @@ -0,0 +1,201 @@ +/* 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 . + */ + +/* Lightpanda embedded as a C library. Implemented by src/c_api.zig, which + * must stay in sync with this header. + * + * Threading contract (v1): + * - lp_init may be called ONCE per process. After lp_shutdown the library + * cannot be initialized again (V8's platform is not re-initializable). + * - Every call on the lp_browser handle and on all of its sessions must + * come from the thread that called lp_init. Sessions are cheap and + * isolated; parallelism means multiple processes. + * - The library is not fork-safe after lp_init. + * + * Strings: every string, input and output, is a pointer plus a byte + * length. Inputs need not be NUL-terminated and outputs are not guaranteed + * to be. (The only exceptions are lp_version and lp_tools_json, which + * return static NUL-terminated C strings.) + * + * Logging goes to stderr (level: warnings and errors in release builds). + * + * Anonymous usage telemetry is enabled in release builds; set + * LIGHTPANDA_DISABLE_TELEMETRY in the environment to opt out. + * + * Linking: `make lib` builds liblightpanda.so (liblightpanda.dylib on macOS) + * into zig-out/lib and installs this header (zig-out/include) plus a + * pkg-config file: + * cc app.c $(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) + * or by hand: + * cc app.c -Izig-out/include -Lzig-out/lib -llightpanda + * The library resolves its dependencies internally, and the bundled + * OpenSSL/curl/sqlite are hidden on both platforms, so it is safe to load + * next to a host's own copies — including dlopen'd for FFI (Python ctypes + * etc.). ELF exports nothing but lp_*; Mach-O has no version script, so V8's + * own C++ symbols stay visible there. + */ + +#ifndef LIGHTPANDA_H +#define LIGHTPANDA_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct lp_browser lp_browser; +typedef struct lp_session lp_session; + +typedef enum lp_status { + LP_OK = 0, + LP_ERR_INVALID_PARAMS = 1, + /* No page loaded yet; call goto (or pass a url) first. */ + LP_ERR_FRAME_NOT_LOADED = 2, + LP_ERR_NODE_NOT_FOUND = 3, + LP_ERR_NAVIGATION_FAILED = 4, + /* The cancel hook (lp_session_set_cancel_hook) returned true. */ + LP_ERR_CANCELLED = 5, + LP_ERR_TIMEOUT = 6, + LP_ERR_OUT_OF_MEMORY = 7, + LP_ERR_INTERNAL = 8, + /* API misuse: double init, use after shutdown, NULL handle. */ + LP_ERR_MISUSE = 9 +} lp_status; + +/* Output of lp_fetch and lp_call. text points at len bytes — read-only, + * not NUL-terminated — owned by the library: it stays valid until the next + * lp_call on the same session (for lp_fetch: the next lp_fetch on the same + * browser), or until that session/browser is torn down. Copy it out to keep + * it longer — including before handing it to another thread. lp_session_pump + * does not invalidate it. is_error signals an in-band page-level failure + * (e.g. a JS throw inside evaluate/extract) whose message is in text; the + * call itself still returns LP_OK. */ +typedef struct lp_result { + const char *text; + size_t len; + bool is_error; +} lp_result; + +/* Zero-initialize for defaults: no proxy, default user agent, no HTTP + * cache, 5s HTTP timeout, 30s JS watchdog. */ +typedef struct lp_options { + const char *user_agent; /* NULL: default ("Lightpanda/1.0") */ + size_t user_agent_len; + const char *http_proxy; /* NULL: none */ + size_t http_proxy_len; + const char *http_cache_dir; /* NULL: no persistent HTTP cache */ + size_t http_cache_dir_len; + uint32_t http_timeout_ms; /* 0: default (5000) */ + int32_t watchdog_ms; /* 0: default (30000), <0: disabled */ +} lp_options; + +typedef enum lp_format { + LP_FORMAT_HTML = 0, + LP_FORMAT_MARKDOWN = 1, + LP_FORMAT_TREE_JSON = 2, /* semantic (accessibility-style) tree, JSON */ + LP_FORMAT_TREE_TEXT = 3 /* semantic tree, indented text */ +} lp_format; + +typedef enum lp_wait_until { + LP_WAIT_DEFAULT = 0, /* page fully settled ("done"), or the selector */ + LP_WAIT_LOAD = 1, + LP_WAIT_DOMCONTENTLOADED = 2, + LP_WAIT_NETWORKALMOSTIDLE = 3, + LP_WAIT_NETWORKIDLE = 4, + LP_WAIT_DONE = 5 +} lp_wait_until; + +/* Zero-initialize for defaults: HTML after the page settles, 5s budget. */ +typedef struct lp_fetch_opts { + int format; /* lp_format */ + uint32_t wait_ms; /* 0: default (5000) */ + int wait_until; /* lp_wait_until */ + const char *wait_selector; /* NULL: none; else wait for this CSS selector */ + size_t wait_selector_len; +} lp_fetch_opts; + +/* Initialize the library. opts may be NULL (all defaults). On LP_OK, + * *out is the process-wide browser handle. */ +lp_status lp_init(const lp_options *opts, lp_browser **out); + +/* Tear down the handle, closing any remaining sessions. Terminal — see the + * threading contract above. */ +void lp_shutdown(lp_browser *browser); + +/* Load url in a throwaway session, run its JavaScript, and return the page + * serialized per opts (NULL: all defaults). "curl that runs JavaScript". + * Every call gets a fresh session (cookies, storage, pages); the underlying + * browser is created on first use and reused, so looping lp_fetch is cheap. */ +lp_status lp_fetch(lp_browser *browser, const char *url, size_t url_len, + const lp_fetch_opts *opts, lp_result *out); + +/* Create an isolated browsing session: its own page, cookies, JS heap. */ +lp_status lp_session_new(lp_browser *browser, lp_session **out); + +/* Close a session. The pointer is invalid afterwards. Sessions still open + * at lp_shutdown are closed then. */ +void lp_session_close(lp_session *session); + +/* Run one browser tool (goto, markdown, html, extract, tree, click, fill, + * waitForSelector, evaluate, ...) against the session. args_json is the + * tool's argument object as a JSON string (NULL: no arguments); the tool + * names and their JSON schemas are enumerated by lp_tools_json. Tools that + * read the page accept a "url" argument to navigate first, so + * lp_call(s, "markdown", strlen("markdown"), args, strlen(args), &r) + * with args = "{\"url\":\"https://example.com\"}" is a complete one-call + * scrape. */ +lp_status lp_call(lp_session *session, const char *tool, size_t tool_len, + const char *args_json, size_t args_json_len, + lp_result *out); + +/* Pump background work (timers, in-flight fetches) once; returns how many + * milliseconds the caller may sleep before pumping again. Only needed when + * idling between calls — every call waits for its own completion. */ +uint32_t lp_session_pump(lp_session *session); + +/* Install (cb != NULL) or clear (cb == NULL) a cancellation probe, polled + * on the session's thread during blocking waits; returning true fails the + * in-flight call with LP_ERR_CANCELLED. The probe may read state set from + * other threads (e.g. an atomic flag set by a signal handler). */ +void lp_session_set_cancel_hook(lp_session *session, + bool (*cb)(void *), void *ctx); + +/* Diagnostic for the most recent failing call on this session (lp_call) or + * browser (lp_fetch, lp_session_new): an error name such as "Timeout" or + * "CertificateError", more specific than the lp_status code. Empty when the + * last call succeeded; NULL for a NULL handle or after lp_shutdown. len may + * be NULL. Static — do not free. */ +const char *lp_last_error(lp_session *session, size_t *len); +const char *lp_browser_last_error(lp_browser *browser, size_t *len); + +/* JSON array of every tool lp_call accepts: + * [{"name", "description", "inputSchema"}, ...]. Static — do not free. */ +const char *lp_tools_json(void); + +/* Library version string. Static — do not free. */ +const char *lp_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* LIGHTPANDA_H */ diff --git a/src/Config.zig b/src/Config.zig index 88d9f70b13..5374917118 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -427,6 +427,14 @@ const Commands = cli.Builder(.{ .options = .{}, .shared_options = CommonOptions, }, + .{ + // The C API's mode (src/c_api.zig). Hidden: embedders construct it + // programmatically; it is not typeable on the command line. + .name = "embed", + .hidden = true, + .options = .{}, + .shared_options = CommonOptions, + }, .{ .name = "version", .options = .{ .{ .name = "check", .type = bool }, } }, @@ -472,7 +480,7 @@ pub fn deinit(self: *const Config, allocator: Allocator) void { pub fn interactive(self: *const Config) bool { return switch (self.mode) { - .fetch => false, + .fetch, .embed => false, .serve, .mcp => true, .agent => |opts| opts.script_file == null, else => unreachable, @@ -481,7 +489,7 @@ pub fn interactive(self: *const Config) bool { pub fn tlsVerifyHost(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| !opts.insecure_disable_tls_host_verification, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| !opts.insecure_disable_tls_host_verification, // `version --check` talks to the release endpoint; always verify. .version => true, else => unreachable, @@ -490,28 +498,28 @@ pub fn tlsVerifyHost(self: *const Config) bool { pub fn obeyRobots(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.obey_robots, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.obey_robots, else => unreachable, }; } pub fn disableSubframes(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.disable_subframes, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.disable_subframes, else => unreachable, }; } pub fn disableWorkers(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.disable_workers, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.disable_workers, else => unreachable, }; } pub fn watchdogMs(self: *const Config) ?u32 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| { + inline .serve, .fetch, .mcp, .agent, .embed => |opts| { const ms = opts.watchdog_ms orelse 30000; return if (ms == 0) null else ms; }, @@ -521,28 +529,28 @@ pub fn watchdogMs(self: *const Config) ?u32 { pub fn enableExternalStylesheets(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.enable_external_stylesheets, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.enable_external_stylesheets, else => unreachable, }; } pub fn v8Flags(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.v8_flags_unsafe, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.v8_flags_unsafe, else => unreachable, }; } pub fn v8MaxHeapMb(self: *const Config) ?u32 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.v8_max_heap_mb, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.v8_max_heap_mb, else => unreachable, }; } pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_proxy, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_proxy, .version => null, else => unreachable, }; @@ -550,35 +558,35 @@ pub fn httpProxy(self: *const Config) ?[:0]const u8 { pub fn httpHeaders(self: *const Config) []const HttpHeader { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_header.items, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_header.items, else => &.{}, }; } pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.proxy_bearer_token, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.proxy_bearer_token, else => null, }; } pub fn httpMaxConcurrent(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_concurrent orelse 40, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_max_concurrent orelse 40, else => unreachable, }; } pub fn httpMaxHostOpen(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_host_open orelse 6, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_max_host_open orelse 6, else => unreachable, }; } pub fn httpNavDelay(self: *const Config) ?u32 { const ms = switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_nav_delay, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_nav_delay, else => unreachable, } orelse return null; return if (ms == 0) null else ms; @@ -586,7 +594,7 @@ pub fn httpNavDelay(self: *const Config) ?u32 { pub fn httpNavBurst(self: *const Config) u32 { const burst = switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_nav_burst, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_nav_burst, else => unreachable, } orelse 1; return @max(burst, 1); @@ -594,7 +602,7 @@ pub fn httpNavBurst(self: *const Config) u32 { pub fn httpConnectTimeout(self: *const Config) u31 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_connect_timeout orelse 0, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_connect_timeout orelse 0, .version => 0, else => unreachable, }; @@ -602,7 +610,7 @@ pub fn httpConnectTimeout(self: *const Config) u31 { pub fn httpTimeout(self: *const Config) u31 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_timeout orelse 5000, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_timeout orelse 5000, .version => 5000, else => unreachable, }; @@ -614,14 +622,14 @@ pub fn httpMaxRedirects(_: *const Config) u8 { pub fn httpMaxResponseSize(self: *const Config) ?usize { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_response_size, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_max_response_size, else => unreachable, }; } pub fn wsMaxConcurrent(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.ws_max_concurrent orelse 8, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.ws_max_concurrent orelse 8, else => unreachable, }; } @@ -633,7 +641,7 @@ pub fn logLevel(self: *const Config) ?log.Level { .low, .medium => .err, .high => null, }, - inline .serve, .fetch, .mcp => |opts| opts.log_level, + inline .serve, .fetch, .mcp, .embed => |opts| opts.log_level, else => unreachable, }; } @@ -663,56 +671,56 @@ fn stderrIsTty() bool { pub fn logFormat(self: *const Config) ?log.Format { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.log_format, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.log_format, else => unreachable, }; } pub fn logFilterScopes(self: *const Config) std.ArrayList(log.FilterRule) { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.log_filter_scopes, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.log_filter_scopes, else => unreachable, }; } pub fn userAgentSuffix(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent_suffix, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.user_agent_suffix, else => null, }; } pub fn userAgent(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.user_agent, else => null, }; } pub fn httpCacheDir(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_cache_dir, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_cache_dir, else => null, }; } pub fn httpCacheEntryLimit(self: *const Config) u32 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_cache_entry_limit.?, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_cache_entry_limit.?, else => 1000, }; } pub fn cookieFile(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.cookie, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.cookie, else => null, }; } pub fn cookieJarFile(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .fetch, .mcp, .agent => |opts| opts.cookie_jar, + inline .fetch, .mcp, .agent, .embed => |opts| opts.cookie_jar, else => null, }; } @@ -764,7 +772,7 @@ fn isHostWildcard(host: []const u8) bool { pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| WebBotAuthConfig{ + inline .serve, .fetch, .mcp, .agent, .embed => |opts| WebBotAuthConfig{ .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, @@ -775,21 +783,21 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { pub fn blockPrivateNetworks(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.block_private_networks, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.block_private_networks, else => unreachable, }; } pub fn blockCidrs(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.block_cidrs, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.block_cidrs, else => unreachable, }; } pub fn blockedUrlPatterns(self: *const Config) ?std.mem.SplitIterator(u8, .scalar) { const patterns = switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.block_urls, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.block_urls, else => unreachable, } orelse return null; return std.mem.splitScalar(u8, patterns, ','); @@ -797,7 +805,7 @@ pub fn blockedUrlPatterns(self: *const Config) ?std.mem.SplitIterator(u8, .scala pub fn adblockLists(self: *const Config) ?std.mem.SplitIterator(u8, .scalar) { const paths = switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.adblock_lists, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.adblock_lists, else => unreachable, } orelse return null; return std.mem.splitScalar(u8, paths, ','); @@ -807,7 +815,7 @@ pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, .mcp => 16, - .fetch, .agent => 0, + .fetch, .agent, .embed => 0, else => unreachable, }; } @@ -852,7 +860,7 @@ pub fn cdpMaxHTTPMessageSize(self: *const Config) u14 { /// if any was loaded during argument parsing. The caller takes ownership. pub fn customCertStore(self: *const Config) ?*crypto.X509_STORE { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| { + inline .serve, .fetch, .mcp, .agent, .embed => |opts| { const store = opts.cert.store orelse return null; // Validators guarantee a created store loaded something. lp.assert(opts.cert.count > 0, "empty custom cert store", .{}); @@ -986,6 +994,8 @@ pub fn printUsageAndExit(self: *const Config, allocator: Allocator, help_for: Ru const template = Help.version ++ "\n"; break :text try std.fmt.allocPrint(allocator, template, .{exec_name}); }, + // Hidden command: the CLI parser can never produce it. + .embed => unreachable, }; defer allocator.free(text); diff --git a/src/c_api.zig b/src/c_api.zig new file mode 100644 index 0000000000..13a8f450a9 --- /dev/null +++ b/src/c_api.zig @@ -0,0 +1,754 @@ +// 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 . + +//! C ABI for embedding Lightpanda as a library. The contract lives in +//! include/lightpanda.h; this file must stay in sync with it. +//! +//! Threading contract (v1): `lp_init` may be called once per process (V8's +//! platform cannot be re-initialized after `lp_shutdown`), and every call on +//! the resulting handle — including all its sessions — must come from the +//! thread that called `lp_init`. V8 isolates have thread affinity; sessions +//! park their isolate between calls so many can share that one thread, the +//! same discipline as `mcp.HttpServer`. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const c_allocator = std.heap.c_allocator; + +// Cap on arena capacity kept across result-arena resets: recycle small +// results, return multi-MB page dumps to the OS. +const result_retain_limit = 256 * 1024; + +// Values mirrored in include/lightpanda.h (lp_status). +const Status = enum(c_int) { + ok = 0, + invalid_params = 1, + frame_not_loaded = 2, + node_not_found = 3, + navigation_failed = 4, + cancelled = 5, + timeout = 6, + out_of_memory = 7, + internal = 8, + // API misuse: double init, init after shutdown, null handle. + misuse = 9, +}; + +// Mirrored in include/lightpanda.h (lp_result), which documents the +// result lifetime and string contract. +const Result = extern struct { + text: ?[*]const u8, + len: usize, + is_error: bool, + + const empty: Result = .{ .text = null, .len = 0, .is_error = false }; +}; + +// Mirrored in include/lightpanda.h (lp_options), which documents the +// sentinel values. Zero-initialized means defaults everywhere. +const InitOpts = extern struct { + user_agent: ?[*]const u8, + user_agent_len: usize, + http_proxy: ?[*]const u8, + http_proxy_len: usize, + http_cache_dir: ?[*]const u8, + http_cache_dir_len: usize, + http_timeout_ms: u32, + watchdog_ms: i32, +}; + +// Mirrored in include/lightpanda.h (lp_fetch_opts). Formats and wait +// conditions travel as plain ints so out-of-range values from C are +// rejected instead of being illegal to load. +const FetchOpts = extern struct { + format: c_int, + wait_ms: u32, + wait_until: c_int, + wait_selector: ?[*]const u8, + wait_selector_len: usize, +}; + +// Mirrored in include/lightpanda.h (lp_format). +const Format = enum(c_int) { + html = 0, + markdown = 1, + tree_json = 2, + tree_text = 3, +}; + +// Mirrored in include/lightpanda.h (lp_wait_until). +const WaitUntil = enum(c_int) { + default = 0, + load = 1, + domcontentloaded = 2, + networkalmostidle = 3, + networkidle = 4, + done = 5, +}; + +const BrowserHandle = struct { + config: lp.Config, + app: *lp.App, + // Owns the option strings duped out of the caller's InitOpts. + config_arena: std.heap.ArenaAllocator, + // Owns the previous lp_fetch result; reset at the start of the next one. + fetch_arena: std.heap.ArenaAllocator, + // lp_fetch's browser, created on first use; its isolate parks between + // calls. Heap-allocated: Browser registers self-pointers and must not move. + fetch_browser: ?*lp.Browser, + sessions: std.ArrayList(*SessionHandle), + // Static @errorName of the last failing lp_fetch/lp_session_new. + last_error: []const u8, +}; + +const Cancel = struct { + cb: *const fn (?*anyopaque) callconv(.c) bool, + ctx: ?*anyopaque, +}; + +const SessionHandle = struct { + owner: *BrowserHandle, + ts: lp.ToolSession, + // Per-call scratch and the returned result; the reset at the start of + // the next lp_call is the header's documented result lifetime. + arena: std.heap.ArenaAllocator, + cancel: ?Cancel, + // Static @errorName of the last failing lp_call. + last_error: []const u8, +}; + +// V8's platform is process-global and cannot be re-initialized after +// dispose, so init is once-and-final: live never coexists with a second +// handle, and shutdown is terminal. +const AppState = enum { uninitialized, live, shutdown }; +var app_state: AppState = .uninitialized; + +/// Initialize the library and return the process-wide browser handle. +/// Callable exactly once per process; after `lp_shutdown` it fails with +/// `misuse` forever (V8 cannot be re-initialized). +pub export fn lp_init(opts_: ?*const InitOpts, out_: ?**BrowserHandle) Status { + const out = out_ orelse return .misuse; + if (app_state != .uninitialized) return .misuse; + + const handle = createBrowser(opts_) catch |err| return errStatus(err); + app_state = .live; + out.* = handle; + return .ok; +} + +fn createBrowser(opts_: ?*const InitOpts) !*BrowserHandle { + const handle = try c_allocator.create(BrowserHandle); + errdefer c_allocator.destroy(handle); + + handle.config_arena = .init(c_allocator); + errdefer handle.config_arena.deinit(); + handle.fetch_arena = .init(c_allocator); + errdefer handle.fetch_arena.deinit(); + const arena = handle.config_arena.allocator(); + + var mode: @FieldType(lp.Config.Mode, "embed") = .{}; + if (opts_) |opts| { + if (opts.user_agent) |ua| { + const span = ua[0..opts.user_agent_len]; + lp.Config.validateUserAgent(span) catch return error.InvalidParams; + mode.user_agent = try arena.dupe(u8, span); + } + if (opts.http_proxy) |proxy| { + mode.http_proxy = try arena.dupeZ(u8, proxy[0..opts.http_proxy_len]); + } + if (opts.http_cache_dir) |dir| { + mode.http_cache_dir = try arena.dupe(u8, dir[0..opts.http_cache_dir_len]); + } + if (opts.http_timeout_ms != 0) { + mode.http_timeout = std.math.cast(u31, opts.http_timeout_ms) orelse return error.InvalidParams; + } + // Config.watchdogMs treats 0 as "no watchdog" and null as the default. + if (opts.watchdog_ms < 0) { + mode.watchdog_ms = 0; + } else if (opts.watchdog_ms > 0) { + mode.watchdog_ms = @intCast(opts.watchdog_ms); + } + } + + handle.config = try lp.Config.init(c_allocator, "lightpanda", .{ .embed = mode }); + errdefer handle.config.deinit(c_allocator); + + // Everything above is plain validation and can be retried; App.init + // touches V8's process-global platform, so a failure here poisons the + // process like a shutdown does. + handle.app = lp.App.init(c_allocator, &handle.config) catch |err| { + app_state = .shutdown; + return err; + }; + handle.fetch_browser = null; + handle.sessions = .empty; + handle.last_error = ""; + return handle; +} + +/// Tear down every remaining session and the process-wide state. Terminal: +/// the library cannot be initialized again in this process. +pub export fn lp_shutdown(handle_: ?*BrowserHandle) void { + const handle = handle_ orelse return; + if (app_state != .live) return; + + while (handle.sessions.pop()) |session| destroySession(session); + handle.sessions.deinit(c_allocator); + if (handle.fetch_browser) |browser| { + // Browser.deinit's Env.deinit exit balances against this enter. + browser.env.isolate.enter(); + browser.deinit(); + c_allocator.destroy(browser); + } + handle.app.deinit(); + handle.config.deinit(c_allocator); + handle.fetch_arena.deinit(); + handle.config_arena.deinit(); + c_allocator.destroy(handle); + app_state = .shutdown; +} + +/// Load `url` in a throwaway session and return the page serialized per +/// `opts` (default: HTML after the page settles). NULL `opts` means all +/// defaults. `out` stays valid until the next `lp_fetch` on this handle or +/// `lp_shutdown`. +pub export fn lp_fetch( + handle_: ?*BrowserHandle, + url_: ?[*]const u8, + url_len: usize, + opts_: ?*const FetchOpts, + out_: ?*Result, +) Status { + const handle = handle_ orelse return .misuse; + const out = out_ orelse return .misuse; + out.* = .empty; + if (app_state != .live) return .misuse; + const url_ptr = url_ orelse return .invalid_params; + if (url_len == 0) return .invalid_params; + + // At the start, not on the way out: the previous result must stay valid + // until this call begins. The reset precedes the option parsing because + // the input strings are duped into this arena. + _ = handle.fetch_arena.reset(.{ .retain_with_limit = result_retain_limit }); + handle.last_error = ""; + const arena = handle.fetch_arena.allocator(); + const url = arena.dupeZ(u8, url_ptr[0..url_len]) catch return .out_of_memory; + + var fetch_opts: lp.FetchOpts = .{ .dump = .{}, .dump_mode = .html }; + if (opts_) |opts| { + const format = std.enums.fromInt(Format, opts.format) orelse return .invalid_params; + fetch_opts.dump_mode = switch (format) { + .html => .html, + .markdown => .markdown, + .tree_json => .semantic_tree, + .tree_text => .semantic_tree_text, + }; + if (opts.wait_ms != 0) fetch_opts.wait_ms = opts.wait_ms; + const wait_until = std.enums.fromInt(WaitUntil, opts.wait_until) orelse return .invalid_params; + fetch_opts.wait_until = switch (wait_until) { + .default => null, + .load => .load, + .domcontentloaded => .domcontentloaded, + .networkalmostidle => .networkalmostidle, + .networkidle => .networkidle, + .done => .done, + }; + if (opts.wait_selector) |selector| { + fetch_opts.wait_selector = arena.dupeZ(u8, selector[0..opts.wait_selector_len]) catch return .out_of_memory; + } + } + + // Seeded so growth doubling doesn't strand superseded buffers in the + // arena (it cannot free them); the seed itself is covered by the + // retained chunk. + var writer = std.Io.Writer.Allocating.initCapacity(arena, result_retain_limit) catch return .out_of_memory; + fetch_opts.writer = &writer.writer; + + // Sessions park their isolate between calls, so entering this + // browser's isolate here nests correctly. Browser.init leaves the + // isolate entered; the exit below parks it either way. + if (handle.fetch_browser) |browser| { + browser.env.isolate.enter(); + } else { + const browser = c_allocator.create(lp.Browser) catch return .out_of_memory; + browser.init(handle.app, .{}, null) catch |err| { + c_allocator.destroy(browser); + handle.last_error = @errorName(err); + return errStatus(err); + }; + handle.fetch_browser = browser; + } + const browser = handle.fetch_browser.?; + defer browser.env.isolate.exit(); + + lp.fetch(handle.app, browser, &.{url}, fetch_opts) catch |err| { + handle.last_error = @errorName(err); + return errStatus(err); + }; + + const text = writer.written(); + out.* = .{ .text = text.ptr, .len = text.len, .is_error = false }; + return .ok; +} + +/// Create an isolated browsing session (its own V8 isolate, page, cookies +/// and memory). Close with `lp_session_close`; any session still open at +/// `lp_shutdown` is closed then. +pub export fn lp_session_new(handle_: ?*BrowserHandle, out_: ?**SessionHandle) Status { + const handle = handle_ orelse return .misuse; + const out = out_ orelse return .misuse; + if (app_state != .live) return .misuse; + + out.* = createSession(handle) catch |err| { + handle.last_error = @errorName(err); + return errStatus(err); + }; + handle.last_error = ""; + return .ok; +} + +/// Exhaustive so a new ToolError tag forces a mapping decision at +/// compile time. +fn toolStatus(err: lp.tools.ToolError) Status { + return switch (err) { + error.FrameNotLoaded => .frame_not_loaded, + error.InvalidParams => .invalid_params, + error.NodeNotFound => .node_not_found, + error.NavigationFailed => .navigation_failed, + error.Cancelled => .cancelled, + error.Timeout => .timeout, + error.InternalError => .internal, + error.OutOfMemory => .out_of_memory, + }; +} + +/// For the anyerror paths (init, fetch): tool errors keep their toolStatus +/// mapping, anything else is internal. +fn errStatus(err: anyerror) Status { + inline for (@typeInfo(lp.tools.ToolError).error_set.?) |e| { + const tool_err = @field(lp.tools.ToolError, e.name); + if (err == tool_err) return toolStatus(tool_err); + } + return .internal; +} + +fn createSession(handle: *BrowserHandle) !*SessionHandle { + const entry = try c_allocator.create(SessionHandle); + errdefer c_allocator.destroy(entry); + + entry.owner = handle; + entry.cancel = null; + entry.last_error = ""; + entry.arena = .init(c_allocator); + errdefer entry.arena.deinit(); + + try entry.ts.init(handle.app); + errdefer entry.ts.deinit(); + + entry.ts.session.cancel_hook = .{ .context = entry, .check = cancelTrampoline }; + + try handle.sessions.append(c_allocator, entry); + + // ToolSession.init left the isolate entered; park it so sessions can + // share the thread. + entry.ts.exitIsolate(); + return entry; +} + +/// Close a session created by `lp_session_new`, freeing its isolate, page +/// and memory. The handle is invalid afterwards. +pub export fn lp_session_close(entry_: ?*SessionHandle) void { + const entry = entry_ orelse return; + // After lp_shutdown every session is already destroyed; a stale handle + // must be inert, not a use-after-free. + if (app_state != .live) return; + const sessions = &entry.owner.sessions; + for (sessions.items, 0..) |session, i| { + if (session == entry) { + _ = sessions.swapRemove(i); + break; + } + } + destroySession(entry); +} + +fn destroySession(entry: *SessionHandle) void { + entry.ts.enterIsolate(); + entry.ts.deinit(); + entry.arena.deinit(); + c_allocator.destroy(entry); +} + +/// Run one browser tool against the session. `tool` is a name from +/// `lp_tools_json` (goto, markdown, extract, click, …); `args_json` is that +/// tool's argument object as a JSON string, or NULL for no arguments. On +/// `LP_OK`, `out` stays valid until the next `lp_call` on this session or +/// `lp_session_close`; `out->is_error` signals an in-band page-level +/// failure (e.g. a JS throw inside evaluate/extract) whose message is in +/// `out->text`. +pub export fn lp_call( + entry_: ?*SessionHandle, + tool_: ?[*]const u8, + tool_len: usize, + args_json_: ?[*]const u8, + args_json_len: usize, + out_: ?*Result, +) Status { + const entry = entry_ orelse return .misuse; + const out = out_ orelse return .misuse; + out.* = .empty; + if (app_state != .live) return .misuse; + const tool = tool_ orelse return .invalid_params; + + // At the start, not on the way out: the previous result must stay + // valid until this call begins. + _ = entry.arena.reset(.{ .retain_with_limit = result_retain_limit }); + entry.last_error = ""; + const arena = entry.arena.allocator(); + + var args: ?std.json.Value = null; + if (args_json_) |args_json| { + args = std.json.parseFromSliceLeaky(std.json.Value, arena, args_json[0..args_json_len], .{}) catch |err| { + entry.last_error = @errorName(err); + return .invalid_params; + }; + } + + entry.ts.enterIsolate(); + defer entry.ts.exitIsolate(); + + const result = lp.tools.call(arena, entry.ts.session, &entry.ts.registry, tool[0..tool_len], args) catch |err| { + entry.last_error = @errorName(err); + return toolStatus(err); + }; + + out.* = .{ .text = result.text.ptr, .len = result.text.len, .is_error = result.is_error }; + return .ok; +} + +/// Pump the session's background work (timers, in-flight fetches, resumed +/// JS) once. Returns the number of milliseconds the caller may sleep before +/// pumping again. Only needed when idling between calls; every tool call +/// already waits for its own completion. +pub export fn lp_session_pump(entry_: ?*SessionHandle) u32 { + const entry = entry_ orelse return 0; + if (app_state != .live) return 0; + entry.ts.enterIsolate(); + defer entry.ts.exitIsolate(); + return entry.ts.session.idleSlice(); +} + +/// Install (or clear, with NULL `cb`) a cancellation probe polled during +/// blocking waits: once it returns true, the in-flight call fails with +/// `LP_ERR_CANCELLED`. The probe is invoked on the session's thread, but it +/// may read state set by another thread (e.g. an atomic flag flipped by a +/// signal handler). +pub export fn lp_session_set_cancel_hook( + entry_: ?*SessionHandle, + cb: ?*const fn (?*anyopaque) callconv(.c) bool, + ctx: ?*anyopaque, +) void { + const entry = entry_ orelse return; + if (app_state != .live) return; + entry.cancel = if (cb) |f| .{ .cb = f, .ctx = ctx } else null; +} + +fn cancelTrampoline(ctx: *anyopaque) bool { + const entry: *SessionHandle = @ptrCast(@alignCast(ctx)); + const cancel = entry.cancel orelse return false; + return cancel.cb(cancel.ctx); +} + +/// Error name of the most recent failing `lp_call` on this session; empty +/// when the last call succeeded. Static storage — do not free. +pub export fn lp_last_error(entry_: ?*SessionHandle, len_: ?*usize) ?[*]const u8 { + const entry = entry_ orelse return publishError(null, len_); + return publishError(if (app_state == .live) entry.last_error else null, len_); +} + +/// Like `lp_last_error`, for the browser-level calls (`lp_fetch`, +/// `lp_session_new`). +pub export fn lp_browser_last_error(handle_: ?*BrowserHandle, len_: ?*usize) ?[*]const u8 { + const handle = handle_ orelse return publishError(null, len_); + return publishError(if (app_state == .live) handle.last_error else null, len_); +} + +fn publishError(err: ?[]const u8, len_: ?*usize) ?[*]const u8 { + const e = err orelse { + if (len_) |len| len.* = 0; + return null; + }; + if (len_) |len| len.* = e.len; + return e.ptr; +} + +/// JSON array describing every tool `lp_call` accepts, in the MCP +/// tools/list wire shape: [{"name", "description", "inputSchema"}, …]. +/// Static storage — do not free. +pub export fn lp_tools_json() [*:0]const u8 { + tools_json_once.call(); + return tools_json.ptr; +} + +var tools_json: [:0]const u8 = undefined; +var tools_json_once = lp.once(buildToolsJson); + +fn buildToolsJson() void { + var writer: std.Io.Writer.Allocating = .init(c_allocator); + defer writer.deinit(); + writeToolsJson(&writer.writer) catch @panic("OOM"); + tools_json = writer.toOwnedSliceSentinel(0) catch @panic("OOM"); +} + +// Rendered from the protocol-neutral lp.tools.tool_defs: the shape is this +// ABI's contract (see the header), independent of how the MCP adapter +// evolves its own tools/list wire type. +fn writeToolsJson(w: *std.Io.Writer) !void { + var jw: std.json.Stringify = .{ .writer = w }; + try jw.beginArray(); + for (lp.tools.names, lp.tools.tool_defs) |name, def| { + try jw.beginObject(); + try jw.objectField("name"); + try jw.write(name); + try jw.objectField("description"); + try jw.write(def.description); + try jw.objectField("inputSchema"); + _ = try jw.beginWriteRaw(); + try jw.writer.writeAll(def.input_schema); + jw.endWriteRaw(); + try jw.endObject(); + } + try jw.endArray(); +} + +/// The library version. Static storage — do not free. +pub export fn lp_version() [*:0]const u8 { + return version.ptr; +} + +const version: [:0]const u8 = lp.build_config.version ++ ""; + +const testing = std.testing; + +test "c_api: mirrors the header ABI" { + const h = @import("lightpanda_h"); + + try testing.expectEqual(h.LP_OK, @intFromEnum(Status.ok)); + try testing.expectEqual(h.LP_ERR_INVALID_PARAMS, @intFromEnum(Status.invalid_params)); + try testing.expectEqual(h.LP_ERR_FRAME_NOT_LOADED, @intFromEnum(Status.frame_not_loaded)); + try testing.expectEqual(h.LP_ERR_NODE_NOT_FOUND, @intFromEnum(Status.node_not_found)); + try testing.expectEqual(h.LP_ERR_NAVIGATION_FAILED, @intFromEnum(Status.navigation_failed)); + try testing.expectEqual(h.LP_ERR_CANCELLED, @intFromEnum(Status.cancelled)); + try testing.expectEqual(h.LP_ERR_TIMEOUT, @intFromEnum(Status.timeout)); + try testing.expectEqual(h.LP_ERR_OUT_OF_MEMORY, @intFromEnum(Status.out_of_memory)); + try testing.expectEqual(h.LP_ERR_INTERNAL, @intFromEnum(Status.internal)); + try testing.expectEqual(h.LP_ERR_MISUSE, @intFromEnum(Status.misuse)); + + try testing.expectEqual(h.LP_FORMAT_HTML, @intFromEnum(Format.html)); + try testing.expectEqual(h.LP_FORMAT_MARKDOWN, @intFromEnum(Format.markdown)); + try testing.expectEqual(h.LP_FORMAT_TREE_JSON, @intFromEnum(Format.tree_json)); + try testing.expectEqual(h.LP_FORMAT_TREE_TEXT, @intFromEnum(Format.tree_text)); + + try testing.expectEqual(h.LP_WAIT_DEFAULT, @intFromEnum(WaitUntil.default)); + try testing.expectEqual(h.LP_WAIT_LOAD, @intFromEnum(WaitUntil.load)); + try testing.expectEqual(h.LP_WAIT_DOMCONTENTLOADED, @intFromEnum(WaitUntil.domcontentloaded)); + try testing.expectEqual(h.LP_WAIT_NETWORKALMOSTIDLE, @intFromEnum(WaitUntil.networkalmostidle)); + try testing.expectEqual(h.LP_WAIT_NETWORKIDLE, @intFromEnum(WaitUntil.networkidle)); + try testing.expectEqual(h.LP_WAIT_DONE, @intFromEnum(WaitUntil.done)); + + try expectSameLayout(h.lp_result, Result); + try expectSameLayout(h.lp_options, InitOpts); + try expectSameLayout(h.lp_fetch_opts, FetchOpts); + + // The exports are pub so this reflection sees them; a header prototype + // must exist (compile error otherwise) and agree on arity and sizes. + inline for (@typeInfo(@This()).@"struct".decls) |decl| { + if (comptime std.mem.startsWith(u8, decl.name, "lp_")) { + try expectSameSignature(@TypeOf(@field(h, decl.name)), @TypeOf(@field(@This(), decl.name))); + } + } + + // The lines above only prove the Zig side exists in the header; the + // counts catch a constant or function added to the header alone. + try testing.expectEqual(@typeInfo(Format).@"enum".fields.len, comptime countPrefixed(h, "LP_FORMAT_")); + try testing.expectEqual(@typeInfo(WaitUntil).@"enum".fields.len, comptime countPrefixed(h, "LP_WAIT_")); + try testing.expectEqual(@typeInfo(Status).@"enum".fields.len, comptime countPrefixed(h, "LP_ERR_") + 1); // + LP_OK + try testing.expectEqual(comptime countFns(@This(), "lp_"), comptime countFns(h, "lp_")); +} + +fn countPrefixed(comptime T: type, comptime prefix: []const u8) usize { + @setEvalBranchQuota(100_000); + comptime var n: usize = 0; + inline for (@typeInfo(T).@"struct".decls) |decl| { + if (comptime std.mem.startsWith(u8, decl.name, prefix)) n += 1; + } + return n; +} + +fn countFns(comptime T: type, comptime prefix: []const u8) usize { + @setEvalBranchQuota(100_000); + comptime var n: usize = 0; + inline for (@typeInfo(T).@"struct".decls) |decl| { + if (comptime std.mem.startsWith(u8, decl.name, prefix) and + @typeInfo(@TypeOf(@field(T, decl.name))) == .@"fn") n += 1; + } + return n; +} + +/// Opaque handles make C-vs-Zig type identity meaningless, but every +/// mismatch that matters at the call boundary shows up as an arity or a +/// size difference. +fn expectSameSignature(comptime C: type, comptime Zig: type) !void { + const c_fn = @typeInfo(C).@"fn"; + const zig_fn = @typeInfo(Zig).@"fn"; + try testing.expectEqual(c_fn.params.len, zig_fn.params.len); + try testing.expectEqual(@sizeOf(c_fn.return_type.?), @sizeOf(zig_fn.return_type.?)); + // Over the min so an arity drift fails the expectEqual above instead + // of breaking the unroll. + inline for (0..@min(c_fn.params.len, zig_fn.params.len)) |i| { + try testing.expectEqual(@sizeOf(c_fn.params[i].type.?), @sizeOf(zig_fn.params[i].type.?)); + } +} + +fn expectSameLayout(comptime C: type, comptime Zig: type) !void { + try testing.expectEqual(@sizeOf(C), @sizeOf(Zig)); + inline for (@typeInfo(Zig).@"struct".fields) |field| { + try testing.expectEqual(@offsetOf(C, field.name), @offsetOf(Zig, field.name)); + try testing.expectEqual(@sizeOf(@FieldType(C, field.name)), @sizeOf(field.type)); + } +} + +test "c_api: defaults match the header's documented values" { + var config = try lp.Config.init(testing.allocator, "lightpanda", .{ .embed = .{} }); + defer config.deinit(testing.allocator); + try testing.expectEqual(5000, config.httpTimeout()); + try testing.expectEqual(30000, config.watchdogMs()); + try testing.expectEqual(5000, (lp.FetchOpts{ .dump = .{} }).wait_ms); +} + +test "c_api: version matches the build" { + try testing.expectEqualStrings(lp.build_config.version, std.mem.span(lp_version())); +} + +test "c_api: tools_json is valid JSON covering every tool" { + const parsed = try std.json.parseFromSlice( + std.json.Value, + testing.allocator, + std.mem.span(lp_tools_json()), + .{}, + ); + defer parsed.deinit(); + + const list = parsed.value.array.items; + try testing.expectEqual(lp.tools.names.len, list.len); + for (list, lp.tools.names) |entry, name| { + try testing.expectEqualStrings(name, entry.object.get("name").?.string); + try testing.expect(entry.object.get("description").?.string.len > 0); + try testing.expect(entry.object.get("inputSchema").? == .object); + } +} + +test "c_api: null handles are rejected" { + var result: Result = .empty; + try testing.expectEqual(.misuse, lp_init(null, null)); + try testing.expectEqual(.misuse, lp_call(null, "getUrl", "getUrl".len, null, 0, &result)); + try testing.expectEqual(.misuse, lp_session_new(null, null)); + lp_session_close(null); + try testing.expectEqual(null, result.text); + try testing.expectEqual(null, lp_last_error(null, null)); + try testing.expectEqual(null, lp_browser_last_error(null, null)); +} + +test "c_api: lifecycle" { + var browser: *BrowserHandle = undefined; + + // Option validation happens before V8 is touched, so a rejected init + // must leave the library initializable. + const bad_opts: InitOpts = .{ + .user_agent = "bad\nagent", + .user_agent_len = "bad\nagent".len, + .http_proxy = null, + .http_proxy_len = 0, + .http_cache_dir = null, + .http_cache_dir_len = 0, + .http_timeout_ms = 0, + .watchdog_ms = 0, + }; + try testing.expectEqual(.invalid_params, lp_init(&bad_opts, &browser)); + + try testing.expectEqual(.ok, lp_init(null, &browser)); + + var second: *BrowserHandle = undefined; + try testing.expectEqual(.misuse, lp_init(null, &second)); + + var session: *SessionHandle = undefined; + try testing.expectEqual(.ok, lp_session_new(browser, &session)); + + var result: Result = .empty; + // Unknown tool names come back in-band (is_error) so an LLM caller can + // read them; see tools.call. + try testing.expectEqual(.ok, lp_call(session, "nosuchtool", "nosuchtool".len, null, 0, &result)); + try testing.expect(result.is_error); + try testing.expectEqual(.invalid_params, lp_call(session, "goto", "goto".len, null, 0, &result)); + try testing.expectEqual(.invalid_params, lp_call(session, "goto", "goto".len, "not json", "not json".len, &result)); + + // The failing call left its error name behind; a successful one clears it. + var err_len: usize = 0; + try testing.expect(lp_last_error(session, &err_len) != null); + try testing.expect(err_len > 0); + + try testing.expectEqual(.ok, lp_call(session, "getEnv", "getEnv".len, null, 0, &result)); + try testing.expect(result.text != null); + try testing.expect(result.len > 0); + _ = lp_last_error(session, &err_len); + try testing.expectEqual(0, err_len); + _ = lp_browser_last_error(browser, &err_len); + try testing.expectEqual(0, err_len); + + // Lengths are the contract: trailing garbage past them must be ignored. + const padded_tool = "getEnvGARBAGE"; + const padded_args = "{\"name\":\"PATH\"}GARBAGE"; + try testing.expectEqual(.ok, lp_call(session, padded_tool, "getEnv".len, padded_args, "{\"name\":\"PATH\"}".len, &result)); + try testing.expect(result.text != null); + + // Pumping must not invalidate a held result. + var held: [16]u8 = undefined; + const held_len = @min(result.len, held.len); + @memcpy(held[0..held_len], result.text.?[0..held_len]); + try testing.expect(lp_session_pump(session) > 0); + try testing.expect(std.mem.eql(u8, held[0..held_len], result.text.?[0..held_len])); + + // Twice: the second call reuses the lazily-created fetch browser; the + // second url carries trailing garbage past its length. + try testing.expectEqual(.ok, lp_fetch(browser, "about:blank", "about:blank".len, null, &result)); + try testing.expect(result.text != null); + try testing.expectEqual(.ok, lp_fetch(browser, "about:blankGARBAGE", "about:blank".len, null, &result)); + try testing.expect(result.len > 0); + + lp_session_close(session); + lp_shutdown(browser); + + // Terminal: V8 cannot be re-initialized after dispose. + try testing.expectEqual(.misuse, lp_init(null, &second)); + + // Stale handles are inert after shutdown — no deref, no crash. + try testing.expectEqual(0, lp_session_pump(session)); + lp_session_close(session); + lp_session_set_cancel_hook(session, null, null); +} diff --git a/src/cli.zig b/src/cli.zig index 75472eef62..826d46ebb8 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -182,6 +182,13 @@ pub fn Builder(comptime commands: anytype) type { return struct { const Self = @This(); + /// Hidden commands exist in `Enum`/`Union` (for programmatic + /// construction, e.g. the C API's `embed`) but are not parseable + /// and never appear in help. + fn isHidden(comptime command: anytype) bool { + return @hasField(@TypeOf(command), "hidden") and command.hidden; + } + /// Enum type for provided commands. pub const Enum = blk: { const len = commands.len + 1; @@ -385,6 +392,7 @@ pub fn Builder(comptime commands: anytype) type { const cmd_str: []const u8 = args.next() orelse "serve"; inline for (commands) |command| { + if (comptime isHidden(command)) continue; // Match a command. if (std.mem.eql(u8, cmd_str, command.name)) { const cmd_parsed = try parseCommand(allocator, command, &args); @@ -401,6 +409,7 @@ pub fn Builder(comptime commands: anytype) type { }; inline for (commands) |command| { + if (comptime isHidden(command)) continue; if (std.mem.eql(u8, command_name, command.name)) { return .{ exec_name, @@ -435,6 +444,7 @@ pub fn Builder(comptime commands: anytype) type { _ = args.skip(); inline for (commands) |command| { + if (comptime isHidden(command)) continue; if (std.mem.eql(u8, @tagName(command_enum), command.name)) { const cmd_parsed = try parseCommand(allocator, command, &args); return .{ exec_name, cmd_parsed }; diff --git a/src/lightpanda.map b/src/lightpanda.map new file mode 100644 index 0000000000..21fabea85c --- /dev/null +++ b/src/lightpanda.map @@ -0,0 +1,9 @@ +/* liblightpanda.so exports exactly the C API; everything else (BoringSSL, + * curl, sqlite, V8, zig runtime) stays local so a host linking its own + * copies cannot interpose. */ +{ + global: + lp_*; + local: + *; +}; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index a8337ab2a7..296032c8fb 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -176,6 +176,52 @@ pub fn Once(comptime f: fn () void) type { }; } +/// Everything a tool-driving embedder owns per isolated browsing context. +/// Used by the C API. `self` must not move after `init` — Browser registers +/// self-pointers. +pub const ToolSession = struct { + browser: Browser, + session: *Session, + notification: *Notification, + registry: CDPNode.Registry, + + /// Leaves the browser's isolate entered, like `Browser.init`; callers + /// sharing one thread between several isolates park it with + /// `exitIsolate` afterwards. + pub fn init(self: *ToolSession, app: *App) !void { + self.notification = try Notification.init(app.allocator); + errdefer self.notification.deinit(); + + self.registry = .init(app.allocator); + errdefer self.registry.deinit(); + + try self.browser.init(app, .{}, null); + errdefer self.browser.deinit(); + + self.session = try self.browser.newSession(self.notification); + try self.session.enableConsoleCapture(); + } + + /// The isolate must be current (`enterIsolate` if parked): Browser.deinit's + /// Env.deinit exit has to balance against this context's isolate. + pub fn deinit(self: *ToolSession) void { + self.registry.deinit(); + self.browser.deinit(); + self.notification.deinit(); + } + + /// V8's "current isolate" is a per-thread stack: when several contexts + /// share a thread, bracket any use of the Browser/Session with + /// enterIsolate/exitIsolate and leave it un-entered otherwise. + pub fn enterIsolate(self: *ToolSession) void { + self.browser.env.isolate.enter(); + } + + pub fn exitIsolate(self: *ToolSession) void { + self.browser.env.isolate.exit(); + } +}; + pub const FetchOpts = struct { wait_ms: u32 = 5000, wait_until: ?Config.WaitUntil = null, diff --git a/src/telemetry/lightpanda.zig b/src/telemetry/lightpanda.zig index 133dc1fcb2..b94ece0b76 100644 --- a/src/telemetry/lightpanda.zig +++ b/src/telemetry/lightpanda.zig @@ -83,6 +83,7 @@ pub fn init(self: *LightPanda, app: *App, iid: ?[36]u8, run_mode: Config.RunMode .agent => if (interactive == false) "AR" else "A", .run => "R", .mcp => "M", + .embed => "E", .version => "V", .help => "H", },