Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,49 @@ jobs:

- name: Run tests
run: zig build test -Doptimize=ReleaseSafe

startup-smoke:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Zig 0.15.0
uses: mlugg/setup-zig@v2
with:
version: 0.15.0

- name: Install Chrome
uses: browser-actions/setup-chrome@v2
with:
install-dependencies: true

- name: Build
run: zig build -Doptimize=ReleaseSafe

- name: Startup smoke test
run: |
set -euo pipefail
./zig-out/bin/kuri > kuri.log 2>&1 &
kuri_pid=$!
cleanup() {
kill "${kuri_pid}" >/dev/null 2>&1 || true
wait "${kuri_pid}" >/dev/null 2>&1 || true
cat kuri.log
}
trap cleanup EXIT

for _ in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/health > health.json; then
break
fi
sleep 1
done

curl -sf http://127.0.0.1:8080/health | tee health.json
curl -sf http://127.0.0.1:8080/tabs | tee tabs.json

grep '"ok":true' health.json
grep '"tabs":1' health.json
grep '"id":"' tabs.json
23 changes: 22 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,27 @@ zig build test # run 230+ tests
./zig-out/bin/kuri-browse https://example.com
```

### First run, shortest path

```bash
# start the server; if CDP_URL is unset, kuri launches managed Chrome for you
./zig-out/bin/kuri

# discover tabs from that managed browser
curl -s http://127.0.0.1:8080/discover

# inspect the discovered tab list
curl -s http://127.0.0.1:8080/tabs
```

If you already have Chrome running with remote debugging, set `CDP_URL` to either the WebSocket or HTTP endpoint:

```bash
CDP_URL=ws://127.0.0.1:9222/devtools/browser/... ./zig-out/bin/kuri
# or
CDP_URL=http://127.0.0.1:9222 ./zig-out/bin/kuri
```

### Browse vercel.com in 4 commands

```bash
Expand Down Expand Up @@ -556,7 +577,7 @@ kuri/
|---------|---------|-------------|
| `HOST` | `127.0.0.1` | Server bind address |
| `PORT` | `8080` | Server port |
| `CDP_URL` | *(none)* | Connect to existing Chrome (`ws://127.0.0.1:9222`) |
| `CDP_URL` | *(none)* | Connect to existing Chrome (`ws://...` or `http://127.0.0.1:9222`) |
| `KURI_SECRET` | *(none)* | Auth secret for API requests |
| `STATE_DIR` | `.kuri` | Session state directory |
| `REQUEST_TIMEOUT_MS` | `30000` | HTTP request timeout |
Expand Down
66 changes: 63 additions & 3 deletions src/bridge/bridge.zig
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@ pub const RefCache = struct {
};
}

pub fn clear(self: *RefCache) void {
var it = self.refs.keyIterator();
while (it.next()) |key| {
self.refs.allocator.free(key.*);
}
self.refs.clearRetainingCapacity();
self.node_count = 0;
}

pub fn deinit(self: *RefCache) void {
self.clear();
self.refs.deinit();
}
};
Expand Down Expand Up @@ -64,6 +74,11 @@ pub const Bridge = struct {
}
self.cdp_clients.deinit();

var prev_it = self.prev_snapshots.iterator();
while (prev_it.next()) |entry| {
self.allocator.free(entry.key_ptr.*);
freeSnapshot(self.allocator, entry.value_ptr.*);
}
self.prev_snapshots.deinit();

var snap_it = self.snapshots.valueIterator();
Expand Down Expand Up @@ -134,7 +149,10 @@ pub const Bridge = struct {
const tab = self.tabs.get(tab_id) orelse {
if (self.snapshots.getPtr(tab_id)) |cache| cache.deinit();
_ = self.snapshots.remove(tab_id);
_ = self.prev_snapshots.remove(tab_id);
if (self.prev_snapshots.fetchRemove(tab_id)) |kv| {
self.allocator.free(kv.key);
freeSnapshot(self.allocator, kv.value);
}
if (self.cdp_clients.fetchRemove(tab_id)) |kv| {
kv.value.deinit();
self.allocator.destroy(kv.value);
Expand All @@ -155,7 +173,10 @@ pub const Bridge = struct {

if (self.snapshots.getPtr(tab_id)) |cache| cache.deinit();
_ = self.snapshots.remove(tab_id);
_ = self.prev_snapshots.remove(tab_id);
if (self.prev_snapshots.fetchRemove(tab_id)) |kv| {
self.allocator.free(kv.key);
freeSnapshot(self.allocator, kv.value);
}
if (self.cdp_clients.fetchRemove(tab_id)) |kv| {
kv.value.deinit();
self.allocator.destroy(kv.value);
Expand Down Expand Up @@ -255,7 +276,7 @@ pub const Bridge = struct {
const colon = std.mem.indexOfScalarPos(u8, json, field_pos + field.len, ':') orelse return null;
var i = colon + 1;
while (i < json.len and (json[i] == ' ' or json[i] == '"')) : (i += 1) {}
if (i == 0) return null;
if (i >= json.len) return null;
const val_start = i;
const val_end = std.mem.indexOfScalarPos(u8, json, val_start, '"') orelse return null;
return json[val_start..val_end];
Expand All @@ -279,8 +300,47 @@ pub const Bridge = struct {
};
return rec;
}

pub fn cloneSnapshot(self: *Bridge, snapshot: []const A11yNode) ![]A11yNode {
const copy = try self.allocator.alloc(A11yNode, snapshot.len);
errdefer self.allocator.free(copy);

var initialized: usize = 0;
errdefer {
for (copy[0..initialized]) |node| {
self.allocator.free(node.ref);
self.allocator.free(node.role);
self.allocator.free(node.name);
self.allocator.free(node.value);
}
}

for (snapshot, 0..) |node, i| {
copy[i] = .{
.ref = try self.allocator.dupe(u8, node.ref),
.role = try self.allocator.dupe(u8, node.role),
.name = try self.allocator.dupe(u8, node.name),
.value = try self.allocator.dupe(u8, node.value),
.backend_node_id = node.backend_node_id,
.depth = node.depth,
};
initialized += 1;
}

return copy;
}
};

fn freeSnapshot(allocator: std.mem.Allocator, snapshot: []const A11yNode) void {
for (snapshot) |node| {
allocator.free(node.ref);
allocator.free(node.role);
allocator.free(node.name);
allocator.free(node.value);
}
allocator.free(snapshot);
}

test "bridge init/deinit" {
var bridge = Bridge.init(std.testing.allocator);
defer bridge.deinit();
Expand Down
13 changes: 10 additions & 3 deletions src/bridge/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,23 @@ pub fn load() Config {
.host = std.posix.getenv("HOST") orelse "127.0.0.1",
.port = parsePort() orelse 8080,
.cdp_url = std.posix.getenv("CDP_URL"),
.auth_secret = std.posix.getenv("BROWDIE_SECRET"),
.state_dir = std.posix.getenv("STATE_DIR") orelse ".browdie",
.auth_secret = getenvAny(&.{ "KURI_SECRET", "BROWDIE_SECRET" }),
.state_dir = getenvAny(&.{ "STATE_DIR" }) orelse ".kuri",
.stale_tab_interval_s = parseU32("STALE_TAB_INTERVAL_S") orelse 30,
.request_timeout_ms = parseU32("REQUEST_TIMEOUT_MS") orelse 30_000,
.navigate_timeout_ms = parseU32("NAVIGATE_TIMEOUT_MS") orelse 30_000,
.extensions = std.posix.getenv("BROWDIE_EXTENSIONS"),
.extensions = getenvAny(&.{ "KURI_EXTENSIONS", "BROWDIE_EXTENSIONS" }),
.headless = parseBool("HEADLESS") orelse true,
};
}

fn getenvAny(names: []const []const u8) ?[]const u8 {
for (names) |name| {
if (std.posix.getenv(name)) |value| return value;
}
return null;
}

fn parsePort() ?u16 {
const val = std.posix.getenv("PORT") orelse return null;
return std.fmt.parseInt(u16, val, 10) catch null;
Expand Down
11 changes: 11 additions & 0 deletions src/browse_main.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const std = @import("std");
const markdown = @import("crawler/markdown.zig");
const validator = @import("crawler/validator.zig");

const version = "0.1.0";
const user_agent = "kuri-browse/" ++ version;
Expand Down Expand Up @@ -83,6 +84,16 @@ const Browser = struct {
fn navigate(self: *Browser, url: []const u8) !void {
const resolved = self.resolveUrl(url);

// SSRF validation — block private IPs, metadata endpoints, non-HTTP schemes
validator.validateUrl(resolved) catch |err| {
if (self.color) {
std.debug.print("\x1b[31m✗\x1b[0m blocked: {s} ({s})\n", .{ resolved, @errorName(err) });
} else {
std.debug.print("error: blocked URL: {s} ({s})\n", .{ resolved, @errorName(err) });
}
return error.FetchFailed;
};

if (self.color) {
std.debug.print("\x1b[2m→\x1b[0m loading \x1b[4m{s}\x1b[0m\n", .{resolved});
} else {
Expand Down
28 changes: 20 additions & 8 deletions src/cdp/client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ pub const EventBuffer = struct {
}
}

/// Check if any buffered event matches a method name.
/// Check if any buffered event matches a CDP method name exactly.
pub fn hasEvent(self: *EventBuffer, method: []const u8) bool {
for (self.items[0..self.len]) |item| {
if (item) |ev| {
if (std.mem.indexOf(u8, ev, method) != null) return true;
if (eventMatchesMethod(ev, method)) return true;
}
}
return false;
Expand Down Expand Up @@ -105,8 +105,8 @@ pub const CdpClient = struct {

var ws = &(self.ws orelse return error.ConnectionRefused);

const msg = try self.buildMessage(allocator, method, params_json);
const sent_id = self.next_id.load(.monotonic) - 1; // ID we just used
const sent_id = self.nextId();
const msg = try self.buildMessageWithId(allocator, sent_id, method, params_json);
defer allocator.free(msg);

ws.sendText(msg) catch return error.ConnectionRefused;
Expand Down Expand Up @@ -149,16 +149,20 @@ pub const CdpClient = struct {
return parsed_id == expected_id;
}

/// Build a JSON-RPC message for a CDP command.
pub fn buildMessage(self: *CdpClient, allocator: std.mem.Allocator, method: []const u8, params_json: ?[]const u8) ![]const u8 {
const id = self.nextId();
/// Build a JSON-RPC message for a CDP command with an explicit ID.
pub fn buildMessageWithId(_: *CdpClient, allocator: std.mem.Allocator, id: u32, method: []const u8, params_json: ?[]const u8) ![]const u8 {
if (params_json) |p| {
return std.fmt.allocPrint(allocator, "{{\"id\":{d},\"method\":\"{s}\",\"params\":{s}}}", .{ id, method, p });
} else {
return std.fmt.allocPrint(allocator, "{{\"id\":{d},\"method\":\"{s}\"}}", .{ id, method });
}
}

/// Build a JSON-RPC message for a CDP command (auto-assigns next ID).
pub fn buildMessage(self: *CdpClient, allocator: std.mem.Allocator, method: []const u8, params_json: ?[]const u8) ![]const u8 {
return self.buildMessageWithId(allocator, self.nextId(), method, params_json);
}

pub fn disconnect(self: *CdpClient) void {
if (self.ws) |*ws| {
ws.close();
Expand All @@ -177,7 +181,7 @@ pub const CdpClient = struct {
var attempts: u32 = 0;
while (attempts < max_attempts) : (attempts += 1) {
const response = ws.receiveMessageAlloc(allocator, 2 * 1024 * 1024) catch return false;
if (std.mem.indexOf(u8, response, method) != null) {
if (eventMatchesMethod(response, method)) {
allocator.free(response);
return true;
}
Expand All @@ -192,6 +196,14 @@ pub const CdpClient = struct {
}
};

fn eventMatchesMethod(event_json: []const u8, method: []const u8) bool {
var match_buf: [256]u8 = undefined;
const match_pattern = std.fmt.bufPrint(&match_buf, "\"method\":\"{s}\"", .{method}) catch {
return std.mem.indexOf(u8, event_json, method) != null;
};
return std.mem.indexOf(u8, event_json, match_pattern) != null;
}

test "CdpClient message building" {
var client = CdpClient.init(std.testing.allocator, "ws://localhost:9222");
defer client.deinit();
Expand Down
Loading
Loading