Getting Started with Your First Contribution to Lightpanda
Walk the README quick start, the Makefile build targets, the CLI run path, the test harness, the src/browser/webapi/ extension point, and CONTRIBUTING.md.
What you will learn
- How the README pins Zig 0.15.2 and the system packages needed to build v8
- How the Makefile chains build-v8-snapshot, build, and build-dev so the snapshot exists before the main build
- How make run dispatches into src/main.zig and starts the CDP server on port 9222
- How make test wraps zig build test with a TEST_FILTER environment variable for targeted runs
- How a Web API class registers itself with v8 through the JsApi struct and links to a paired HTML test fixture
- How CONTRIBUTING.md and the CLA gate every pull request through the CLA assistant action
Prerequisites
- Zig 0.15.2, Rust, and the v8 build prerequisites listed in the README
- Comfort reading Zig and Make
README: Prerequisites and the Zig Version Pin
README.md:203The Build from sources section pins Zig 0.15.2 and lists v8, libcurl, and html5ever as the native dependencies you must satisfy.
A first contributor needs the toolchain installed before anything else compiles. The README pins Zig 0.15.2 exactly; build.zig emits a @compileError if the local Zig is older, so a mismatched version fails fast at build time. The dependency list below it names the three foreign engines this repository links: v8 for JavaScript, libcurl for HTTP, and html5ever for HTML parsing.
The apt package list is the v8 prerequisite, not the Lightpanda one. v8 is built from source out of build.zig, which calls clang on hundreds of C++ files and needs pkg-config and libglib2.0-dev to discover system libraries. Rust is required separately because html5ever is built through cargo. macOS contributors substitute brew install cmake for the apt line.
Pin Zig 0.15.2, install the v8 build dependencies for your platform, and install Rust for html5ever. Mismatched Zig versions fail at the build graph.
## Build from sources
### Prerequisites
Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to
install it with the right version in order to build the project.
Lightpanda also depends on
[v8](https://chromium.googlesource.com/v8/v8.git),
[Libcurl](https://curl.se/libcurl/) and [html5ever](https://github.com/servo/html5ever).
To be able to build the v8 engine, you have to install some libs:
For **Debian/Ubuntu based Linux**:
```
sudo apt install xz-utils ca-certificates \
pkg-config libglib2.0-dev \
clang make curl git
```Makefile: build-v8-snapshot, build, build-dev
Makefile:48The build chain compiles a v8 snapshot first, then the release binary that embeds it, with build-dev as the debug variant.
v8 boots faster from a precomputed startup heap than from a cold init path. Lightpanda generates that heap as src/snapshot.bin and embeds it in the release binary through -Dsnapshot_path. build-v8-snapshot runs first because build declares it as a prerequisite (build: build-v8-snapshot on line 59), so a fresh checkout running make build produces the snapshot, then the release binary that embeds it.
build-dev skips the -Doptimize flag and the snapshot path, falling back to the debug-mode default that creates the snapshot at startup instead. A first contributor should run make build-dev while iterating because debug builds compile faster and surface stack traces; make build is the release-fast variant for performance benchmarking.
Run make build-v8-snapshot then make build for release. Use make build-dev for the debug iteration loop, since release builds re-run the snapshot prerequisite each time.
# $(ZIG) commands
# ------------
.PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end
## Build v8 snapshot
build-v8-snapshot:
@printf "\033[36mBuilding v8 snapshot (release safe)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Build in release-fast mode
build: build-v8-snapshot
@printf "\033[36mBuilding (release fast)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Build in debug mode
build-dev:
@printf "\033[36mBuilding (debug)...\033[0m\n"
@$(ZIG) build || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"main.zig: What make run Actually Starts
src/main.zig:92The serve dispatch parses the host and port, constructs lp.Server, and falls through to a network event loop.
make run wraps ./zig-out/bin/lightpanda with no arguments, which falls into the help mode unless a CLI verb is given. The interesting path is lightpanda serve --host 127.0.0.1 --port 9222, which lands in the .serve branch of this switch. Config.parseArgs earlier in the function converts --host and --port into the opts tagged here, so this code only sees a parsed structure.
The error handling is the part to read carefully. Address.parseIp validates the host or returns an error that the catch block logs and turns into a help-screen exit. Server.init distinguishes error.AddressInUse from other failures with a hint that mentions --port by name. Both patterns reflect a contributor-facing rule: every failure path returns a message a human can act on, never just a Zig error name.
make run hits the .serve switch case, which parses the host and port, constructs lp.Server, and logs an actionable hint when the port is taken.
switch (args.mode) {
.serve => |opts| {
log.debug(.app, "startup", .{ .mode = "serve", .snapshot = app.snapshot.fromEmbedded() });
const address = std.net.Address.parseIp(opts.host, opts.port) catch |err| {
log.fatal(.app, "invalid server address", .{ .err = err, .host = opts.host, .port = opts.port });
return args.printUsageAndExit(false);
};
var server = lp.Server.init(app, address) catch |err| {
if (err == error.AddressInUse) {
log.fatal(.app, "address already in use", .{
.host = opts.host,
.port = opts.port,
.hint = "Another process is already listening on this address. " ++
"Stop the other process or use --port to choose a different port.",
});
} else {
log.fatal(.app, "server run error", .{ .err = err });
}
return err;
};Makefile: make test and the F= Filter
Makefile:80make test wraps zig build test with TEST_FILTER set from F=, piping output through grep so the giant compile command does not flood the terminal.
Tests live inside source files as Zig test "..." blocks; there is no separate tests/ directory. zig build test compiles the lightpanda module under the test target defined in build.zig with src/test_runner.zig as the runner, so make test runs the entire suite by default. F="name" on the command line becomes TEST_FILTER="name", and src/test_runner.zig reads that env var to skip tests whose names do not match.
The script wrapper exists because Zig only emits color codes to a TTY; piping into grep would normally drop the colors. script allocates a pseudo-terminal so the child sees a TTY and prints colors anyway. The grep --line-buffered -v at the end strips the long compile command line that -freference-trace echoes, leaving only test results in the output.
make test runs every test "..." block. F="Page" narrows the run through TEST_FILTER. The script wrapper preserves Zig's color output through the grep pipe.
## Test - `grep` is used to filter out the huge compile command on build
ifeq ($(OS), macos)
test:
@script -q /dev/null sh -c 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' 2>&1 \
| grep --line-buffered -v "^/.*zig test -freference-trace"
else
test:
@script -qec 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' /dev/null 2>&1 \
| grep --line-buffered -v "^/.*zig test -freference-trace"
endifAbortController.zig: The Web API Binding Pattern
src/browser/webapi/AbortController.zig:45JsApi declares the v8 binding through js.Bridge, registers a Meta block, and ends with an inline test that loads an HTML fixture.
Adding a Web API to Lightpanda follows one repeated pattern, and AbortController is among the smallest examples. The JsApi struct is what Env.zig picks up at v8 isolate setup: js.Bridge(AbortController) generates the function-template scaffolding, Meta sets the JS class name and prototype chain, and the trailing constructor, signal, and abort declarations bind Zig methods to JS members through bridge.constructor, bridge.accessor, and bridge.function.
The test pattern at the bottom is just as repeated. testing.htmlRunner loads an HTML file under tests/html/ in a real Page with a real v8 context and asserts on the result; event/abort_controller.html is a plain HTML file that drives the API from JavaScript. Adding an API means adding one Zig file with this shape and one HTML fixture, then watching make test run them both.
Add a Web API by writing a JsApi struct that wires bridge.constructor, accessor, and function calls, then add one HTML fixture under tests/html/ for the inline test.
pub const JsApi = struct {
pub const bridge = js.Bridge(AbortController);
pub const Meta = struct {
pub const name = "AbortController";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const constructor = bridge.constructor(AbortController.init, .{});
pub const signal = bridge.accessor(AbortController.getSignal, null, .{});
pub const abort = bridge.function(AbortController.abort, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: AbortController" {
try testing.htmlRunner("event/abort_controller.html", .{});
}CONTRIBUTING.md: The CLA Gate
CONTRIBUTING.md:1The contributing guide is short. Pull requests go through GitHub and your first one signs the CLA via the CLA assistant action.
The contributing guide is intentionally short and points at one mechanism. Pull requests go through GitHub like any other repo. The wrinkle is the CLA: CLA.md is the agreement, and signing it is a one-time action a contributor takes during the first pull request through a comment on the PR. The cla-assistant-lite GitHub Action posts the prompt and records the acceptance.
PR #303 is the linked example so a first contributor can see the bot comment and the acceptance flow before submitting their own. There is no separate code-style guide here; the project relies on zig fmt for layout and on review for everything else. Discussions happen on the linked Discord and on the GitHub issues page.
One mechanism gates contributions. Open a PR, sign the CLA on first submission through the CLA assistant comment. The repo points at PR #303 as the worked example.
# Contributing
Lightpanda accepts pull requests through GitHub.
You have to sign our [CLA](CLA.md) during your first pull request process
otherwise we're not able to accept your contributions.
The process signature uses the [CLA assistant
lite](https://github.com/marketplace/actions/cla-assistant-lite). You can see
an example of the process in [#303](https://github.com/lightpanda-io/browser/pull/303).README: Where the 123MB and 5s Numbers Come From
README.md:28The benchmarks table comes from a 933-page crawl on AWS m5.large in lightpanda-io/demo and frames the architectural choices the rest of the tour set explores.
The two numbers on the README are why anyone shows up to read this code: 123MB peak versus 2GB peak on 100 pages, 5s versus 46s. They come from a benchmark in lightpanda-io/demo that crawls 933 real web pages on an AWS m5.large, and the methodology lives in BENCHMARKS.md in that companion repo. A first contributor can reproduce the numbers in a few minutes once make build has produced a release binary.
Reading the benchmark numbers is also how the rest of the tour set earns its place. The 123MB is the per-page arena pool plus the moderate v8 memory pressure hint covered in the Zig Memory Management tour. The 9x speed comes from skipping the compositor, the layout pass, and the GPU plumbing covered in the Post-Chrome Browser Architecture tour. Both are linked from the hub.
The 123MB and 5s numbers come from a 933-page crawl on m5.large. The architecture and memory tours explain how the code under src/ produces them.
## Benchmarks
Requesting 933 real web pages over the network on a AWS EC2 m5.large instance.
See [benchmark details](https://github.com/lightpanda-io/demo/blob/main/BENCHMARKS.md#crawler-benchmark).
| Metric | Lightpanda | Headless Chrome | Difference |
| :---- | :---- | :---- | :---- |
| Memory (peak, 100 pages) | 123MB | 2GB | ~16 less |
| Execution time (100 pages) | 5s | 46s | ~9x faster |
## Quick startYou've walked through 7 key areas of the Lightpanda codebase.
Continue: Post-Chrome Browser Architecture → Browse all projectsCreate code tours for your project
Intraview lets AI create interactive walkthroughs of any codebase. Install the free VS Code extension and generate your first tour in minutes.
Install Intraview Free