Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
65a8861
c-api: expose liblightpanda.so and C header for embedding
arrufat Jul 31, 2026
6f2fbb1
build: rename lib-shared and lib-test targets
arrufat Jul 31, 2026
dca3142
Limit exported symbols to lp_* on MacOS
karlseguin Aug 3, 2026
a6f87a7
Merge branch 'main' into c-api
arrufat Aug 3, 2026
1475382
c_api: transition to shutdown state on App.init failure
arrufat Aug 3, 2026
9660160
c_api: ignore stale session handles after shutdown
arrufat Aug 3, 2026
8c0e20c
c-api: use explicit string lengths
arrufat Aug 3, 2026
11f38b9
c_api: add lp_last_error functions
arrufat Aug 3, 2026
c860215
telemetry: remove enable_telemetry option
arrufat Aug 3, 2026
4f8ed85
Merge branch 'c-api' into macos-c-api-export
arrufat Aug 3, 2026
ae4303f
build: clarify symbol hiding comments
arrufat Aug 3, 2026
844dbf5
Merge pull request #3108 from lightpanda-io/macos-c-api-export
arrufat Aug 3, 2026
080c404
c-api: heap-allocate fetch_browser to ensure pointer stability
arrufat Aug 3, 2026
a0db67c
Merge branch 'main' into c-api
arrufat Aug 4, 2026
f315793
Merge branch 'main' into c-api
arrufat Aug 11, 2026
3e2f978
Merge branch 'main' into c-api
arrufat Aug 13, 2026
b46896b
Merge branch 'main' into c-api
arrufat Aug 18, 2026
0d6407b
c_api: support embed config and refine shared lib build
arrufat Aug 18, 2026
79f6191
ci: add c-library test job to zig-test workflow
arrufat Aug 18, 2026
3cf612d
Merge branch 'main' into c-api
arrufat Aug 23, 2026
bbcc2f7
config: support embed mode in http nav delay and burst
arrufat Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/zig-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ on:
paths:
- ".github/**"
- "src/**"
- "include/**"
- "examples/**"
- "build.zig"
- "build.zig.zon"

Expand All @@ -27,6 +29,8 @@ on:
paths:
- ".github/**"
- "src/**"
- "include/**"
- "examples/**"
- "build.zig"
- "build.zig.zon"

Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ else
$(error "Unhandled kernel: $(kernel)")
endif

LIB_EXT := $(if $(filter macos,$(OS)),dylib,so)


# Prebuilt V8
# -----------
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
137 changes: 137 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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,
});
}

Expand All @@ -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",
Expand Down Expand Up @@ -502,20 +633,23 @@ 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",
},
});
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",
},
});
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",
Expand All @@ -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");
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -847,6 +983,7 @@ fn buildCurl(
mod.addCSourceFiles(.{
.root = dep.path("lib"),
.flags = &.{
hide_symbols,
"-D_GNU_SOURCE",
"-DHAVE_CONFIG_H",
"-DCURL_STATICLIB",
Expand Down
4 changes: 2 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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 = .{
Expand Down
41 changes: 41 additions & 0 deletions examples/c/fetch.c
Original file line number Diff line number Diff line change
@@ -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 <lightpanda.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "usage: %s <url>\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;
}
Loading
Loading