Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-05-05 08:05:57 +02:00
62 changed files with 2405 additions and 1446 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.4.3'
default: 'v0.4.4'
v8:
description: 'v8 version to install'
required: false

View File

@@ -1,79 +0,0 @@
name: package archlinux
on:
workflow_call:
permissions:
contents: write
env:
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
jobs:
package:
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
env:
ARCH: ${{ matrix.arch }}
OS: linux
runs-on: ubuntu-22.04
container: archlinux:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Install packaging deps
run: pacman -Syu --noconfirm --needed base-devel sudo
- name: Download linux binary
uses: actions/download-artifact@v4
with:
name: lightpanda-${{ env.ARCH }}-${{ env.OS }}
path: .
- name: Build Arch package
run: |
useradd -m builder
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RAW_VERSION="${{ env.RELEASE }}"
PKGVER="${RAW_VERSION#v}"
PKGREL="1"
echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV"
echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV"
mkdir -p pkg
cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/
cp LICENSE pkg/
cat > pkg/PKGBUILD <<EOF
pkgname=lightpanda
pkgver=${PKGVER}
pkgrel=${PKGREL}
pkgdesc="Lightpanda, headless browser built for AI and automation"
arch=('x86_64' 'aarch64')
url="https://lightpanda.io"
license=('AGPL-3.0-or-later')
options=(!strip !debug)
package() {
install -Dm755 "\$startdir/lightpanda-\$CARCH-linux" "\$pkgdir/usr/bin/lightpanda"
install -Dm644 "\$startdir/LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE"
}
EOF
chown -R builder:builder pkg
cd pkg
sudo -u builder env CARCH=${{ env.ARCH }} makepkg -f --noconfirm -A
- name: Upload Arch package to release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: pkg/lightpanda-${{ env.PKGVER }}-${{ env.PKGREL }}-${{ env.ARCH }}.pkg.tar.zst
tag: ${{ env.RELEASE }}
makeLatest: true

View File

@@ -132,11 +132,6 @@ jobs:
tag: ${{ env.RELEASE }}
makeLatest: true
package-archlinux:
if: github.ref_type == 'tag'
needs: build-linux
uses: ./.github/workflows/package-archlinux.yml
package-debian:
if: github.ref_type == 'tag'
needs: build-linux

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.4.3
ARG ZIG_V8=v0.4.4
ARG TARGETPLATFORM
RUN apt-get update -yq && \

View File

@@ -38,11 +38,24 @@ See [benchmark details](https://github.com/lightpanda-io/demo/blob/main/BENCHMAR
## Quick start
### Install
**Install from the nightly builds**
**Package Managers**
Latest nightly from Homebrew:
```console
brew install lightpanda-io/browser/lightpanda
```
Latest nightly from Arch Linux User Repository:
```console
yay -S lightpanda-nightly-bi
```
**Download from the nightly builds**
You can download the last binary from the [nightly
builds](https://github.com/lightpanda-io/browser/releases/tag/nightly) for
Linux x86_64 and MacOS aarch64.
Linux and MacOS for both x86_64 and aarch64.
*For Linux*
```console

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.3.tar.gz",
.hash = "v8-0.0.0-xddH61GNBABFJ11FJ8KDYXITyjKh4jQ54taEenYek2xJ",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.4.tar.gz",
.hash = "v8-0.0.0-xddH6xiUBACrbPC02HPqL4IOl_1EKAF6zf0IwNKaCILK",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{

View File

@@ -118,6 +118,11 @@ pub const FrameNavigate = struct {
timestamp: u64,
url: [:0]const u8,
opts: Frame.NavigateOpts,
// True when this navigation is being issued against a Page that is in
// .pending state (i.e. an in-flight root navigation whose old Page is
// still alive). CDP uses this to skip BrowserContext.reset() — the old
// page's nodes must remain live and addressable until commit.
is_pending_root: bool = false,
};
pub const FrameNavigated = struct {

View File

@@ -20,10 +20,10 @@ const std = @import("std");
const lp = @import("lightpanda");
const App = @import("App.zig");
const Config = @import("Config.zig");
const CDP = @import("cdp/CDP.zig");
const Net = @import("network/websocket.zig");
const HttpClient = @import("browser/HttpClient.zig");
const Config = @import("Config.zig");
const CDPClient = @import("./browser/HttpClient.zig").CDPClient;
const WsConnection = @import("network/WsConnection.zig");
const log = lp.log;
const net = std.net;
@@ -33,29 +33,29 @@ const Allocator = std.mem.Allocator;
const Server = @This();
app: *App,
allocator: Allocator,
json_version_response: []const u8,
// Thread management
active_threads: std.atomic.Value(u32) = .init(0),
clients: std.ArrayList(*Client) = .{},
client_mutex: std.Thread.Mutex = .{},
clients_pool: std.heap.MemoryPool(Client),
pending: std.ArrayList(*CDP) = .{},
conns: std.ArrayList(*CDP) = .{},
conns_mutex: std.Thread.Mutex = .{},
conns_pool: std.heap.MemoryPool(CDP),
pub fn init(app: *App, address: net.Address) !*Server {
const allocator = app.allocator;
const json_version_response = try buildJSONVersionResponse(app);
errdefer allocator.free(json_version_response);
errdefer app.allocator.free(json_version_response);
const self = try allocator.create(Server);
errdefer allocator.destroy(self);
const self = try app.allocator.create(Server);
errdefer app.allocator.destroy(self);
self.* = .{
.app = app,
.allocator = allocator,
.conns_pool = .init(app.allocator),
.json_version_response = json_version_response,
.clients_pool = std.heap.MemoryPool(Client).init(allocator),
};
errdefer self.conns_pool.deinit();
var bound_address = address;
try self.app.network.bind(&bound_address, self, onAccept);
@@ -65,21 +65,34 @@ pub fn init(app: *App, address: net.Address) !*Server {
}
pub fn shutdown(self: *Server) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
self.conns_mutex.lock();
defer self.conns_mutex.unlock();
for (self.clients.items) |client| {
client.stop();
self.app.network.unbind();
for (self.conns.items) |cdp| {
cdp.browser.env.terminate();
cdp.ws.sendClose();
cdp.ws.shutdown();
}
for (self.pending.items) |conn| {
conn.ws.shutdown();
}
}
pub fn deinit(self: *Server) void {
self.shutdown();
self.joinThreads();
self.clients.deinit(self.allocator);
self.clients_pool.deinit();
self.allocator.free(self.json_version_response);
self.allocator.destroy(self);
while (self.active_threads.load(.monotonic) > 0) {
std.Thread.sleep(10 * std.time.ns_per_ms);
}
self.conns.deinit(self.app.allocator);
self.pending.deinit(self.app.allocator);
self.conns_pool.deinit();
self.app.allocator.free(self.json_version_response);
self.app.allocator.destroy(self);
}
fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void {
@@ -90,102 +103,6 @@ fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void {
};
}
// Liveness is enforced at the TCP layer via keepalive probes sent by the
// kernel. This is transparent to CDP clients — unlike a WebSocket ping, which
// go-rod panics on and chromedp logs as "malformed". Tunables in Config.zig.
fn setTcpKeepalive(socket: posix.socket_t) void {
posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.KEEPALIVE, &std.mem.toBytes(@as(c_int, 1))) catch |err| {
log.warn(.app, "SO_KEEPALIVE", .{ .err = err });
return;
};
const option = switch (@import("builtin").os.tag) {
.macos, .ios => posix.TCP.KEEPALIVE,
else => posix.TCP.KEEPIDLE,
};
posix.setsockopt(socket, posix.IPPROTO.TCP, option, &std.mem.toBytes(Config.CDP_KEEPALIVE_IDLE_S)) catch |err| {
log.warn(.app, "TCP_KEEPIDLE", .{ .err = err });
};
if (@hasDecl(posix.TCP, "KEEPINTVL")) {
posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, &std.mem.toBytes(Config.CDP_KEEPALIVE_INTVL_S)) catch |err| {
log.warn(.app, "TCP_KEEPINTVL", .{ .err = err });
};
}
if (@hasDecl(posix.TCP, "KEEPCNT")) {
posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPCNT, &std.mem.toBytes(Config.CDP_KEEPALIVE_CNT)) catch |err| {
log.warn(.app, "TCP_KEEPCNT", .{ .err = err });
};
}
}
fn handleConnection(self: *Server, socket: posix.socket_t) void {
defer posix.close(socket);
setTcpKeepalive(socket);
// Client is HUGE (> 512KB) because it has a large read buffer.
// V8 crashes if this is on the stack (likely related to its size).
const client = self.getClient() catch |err| {
log.err(.app, "CDP client create", .{ .err = err });
return;
};
defer self.releaseClient(client);
client.* = Client.init(
socket,
self.allocator,
self.app,
self.json_version_response,
) catch |err| {
log.err(.app, "CDP client init", .{ .err = err });
return;
};
defer client.deinit();
self.registerClient(client);
defer self.unregisterClient(client);
// Check shutdown after registering to avoid missing the stop signal.
// If deinit() already iterated over clients, this client won't receive stop()
// and would block joinThreads() indefinitely.
if (self.app.shutdown()) {
return;
}
client.start();
}
fn getClient(self: *Server) !*Client {
self.client_mutex.lock();
defer self.client_mutex.unlock();
return self.clients_pool.create();
}
fn releaseClient(self: *Server, client: *Client) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
self.clients_pool.destroy(client);
}
fn registerClient(self: *Server, client: *Client) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
self.clients.append(self.allocator, client) catch {};
}
fn unregisterClient(self: *Server, client: *Client) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
for (self.clients.items, 0..) |c, i| {
if (c == client) {
_ = self.clients.swapRemove(i);
break;
}
}
}
fn spawnWorker(self: *Server, socket: posix.socket_t) !void {
if (self.app.shutdown()) {
return error.ShuttingDown;
@@ -213,300 +130,109 @@ fn spawnWorker(self: *Server, socket: posix.socket_t) !void {
}
errdefer _ = self.active_threads.fetchSub(1, .monotonic);
const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket });
const thread = try std.Thread.spawn(.{}, handleConnection, .{ self, socket });
thread.detach();
}
fn runWorker(self: *Server, socket: posix.socket_t) void {
fn handleConnection(self: *Server, socket: posix.socket_t) void {
defer _ = self.active_threads.fetchSub(1, .monotonic);
handleConnection(self, socket);
}
defer posix.close(socket);
fn joinThreads(self: *Server) void {
while (self.active_threads.load(.monotonic) > 0) {
std.Thread.sleep(10 * std.time.ns_per_ms);
// CDP is HUGE (> 512KB) because WsConnection has a large read buffer.
// V8 crashes if this is on the stack (likely related to its size).
const cdp = self.allocConn() catch |err| {
log.err(.app, "CDP alloc", .{ .err = err });
return;
};
defer self.releaseConn(cdp);
cdp.init(self.app, socket, self.json_version_response) catch |err| {
log.err(.app, "CDP init", .{ .err = err });
return;
};
defer cdp.deinit();
if (log.enabled(.app, .info)) {
const client_address = cdp.ws.getAddress() catch null;
log.info(.app, "client connected", .{ .ip = client_address });
}
self.registerHandshake(cdp);
const handshake_result = cdp.ws.handshake();
self.unregisterHandshake(cdp);
const upgraded = handshake_result catch |err| {
log.err(.app, "CDP handshake", .{ .err = err });
return;
};
if (!upgraded) return;
self.registerConn(cdp);
defer self.unregisterConn(cdp);
// Check shutdown after registering to avoid missing the stop signal.
// If shutdown() already iterated over conns, this conn won't be terminated
// and would block deinit() indefinitely.
if (self.app.shutdown()) {
return;
}
while (true) {
const next = cdp.tick() catch |err| {
log.err(.app, "cdp tick", .{ .err = err });
return;
};
if (!next) break;
}
}
// Handle exactly one TCP connection.
pub const Client = struct {
// The client is initially serving HTTP requests but, under normal circumstances
// should eventually be upgraded to a websocket connections
mode: union(enum) {
http: void,
cdp: CDP,
},
fn registerHandshake(self: *Server, conn: *CDP) void {
self.conns_mutex.lock();
defer self.conns_mutex.unlock();
allocator: Allocator,
app: *App,
http: *HttpClient,
ws: Net.WsConnection,
self.pending.append(self.app.allocator, conn) catch {};
}
pub fn init(
socket: posix.socket_t,
allocator: Allocator,
app: *App,
json_version_response: []const u8,
) !Client {
var ws = try Net.WsConnection.init(socket, allocator, json_version_response);
errdefer ws.deinit();
fn unregisterHandshake(self: *Server, conn: *CDP) void {
self.conns_mutex.lock();
defer self.conns_mutex.unlock();
if (log.enabled(.app, .info)) {
const client_address = ws.getAddress() catch null;
log.info(.app, "client connected", .{ .ip = client_address });
}
const http = try HttpClient.init(allocator, &app.network);
errdefer http.deinit();
return .{
.allocator = allocator,
.app = app,
.http = http,
.ws = ws,
.mode = .{ .http = {} },
};
}
fn stop(self: *Client) void {
switch (self.mode) {
.http => {},
.cdp => |*cdp| {
cdp.browser.env.terminate();
self.ws.sendClose();
},
}
self.ws.shutdown();
}
pub fn deinit(self: *Client) void {
switch (self.mode) {
.cdp => |*cdp| cdp.deinit(),
.http => {},
}
self.ws.deinit();
self.http.deinit();
}
fn start(self: *Client) void {
const http = self.http;
http.cdp_client = .{
.socket = self.ws.socket,
.ctx = self,
.blocking_read_start = Client.blockingReadStart,
.blocking_read = Client.blockingRead,
.blocking_read_end = Client.blockingReadStop,
};
defer http.cdp_client = null;
self.httpLoop(http) catch |err| {
log.err(.app, "CDP client loop", .{ .err = err });
};
}
fn httpLoop(self: *Client, http: *HttpClient) !void {
lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{});
// Liveness is enforced by TCP keepalive configured in
// Server.setTcpKeepalive; the kernel closes dead sockets, which
// surfaces as EOF/error from readSocket. The loop blocks for ~24 days
// on each poll rather than tracking app-level timeouts. Capped at
// i32-max because HttpClient.tick narrows to c_int.
const wait_ms: u32 = std.math.maxInt(i32);
while (true) {
const status = http.tick(wait_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
};
if (status != .cdp_socket) continue;
if (self.readSocket() == false) {
return;
}
if (self.mode == .cdp) {
break;
}
}
var cdp = &self.mode.cdp;
while (true) {
const result = cdp.pageWait(wait_ms) catch |wait_err| switch (wait_err) {
error.NoPage => {
const status = http.tick(wait_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
};
if (status != .cdp_socket) continue;
if (self.readSocket() == false) {
return;
}
continue;
},
else => return wait_err,
};
switch (result) {
.cdp_socket => {
if (self.readSocket() == false) {
return;
}
},
.done => {},
}
for (self.pending.items, 0..) |w, i| {
if (w == conn) {
_ = self.pending.swapRemove(i);
break;
}
}
}
fn blockingReadStart(ctx: *anyopaque) bool {
const self: *Client = @ptrCast(@alignCast(ctx));
self.ws.setBlocking(true) catch |err| {
log.warn(.app, "CDP blockingReadStart", .{ .err = err });
return false;
};
return true;
}
fn allocConn(self: *Server) !*CDP {
self.conns_mutex.lock();
defer self.conns_mutex.unlock();
return self.conns_pool.create();
}
fn blockingRead(ctx: *anyopaque) bool {
const self: *Client = @ptrCast(@alignCast(ctx));
return self.readSocket();
}
fn releaseConn(self: *Server, conn: *CDP) void {
self.conns_mutex.lock();
defer self.conns_mutex.unlock();
self.conns_pool.destroy(conn);
}
fn blockingReadStop(ctx: *anyopaque) bool {
const self: *Client = @ptrCast(@alignCast(ctx));
self.ws.setBlocking(false) catch |err| {
log.warn(.app, "CDP blockingReadStop", .{ .err = err });
return false;
};
return true;
}
fn registerConn(self: *Server, conn: *CDP) void {
self.conns_mutex.lock();
defer self.conns_mutex.unlock();
self.conns.append(self.app.allocator, conn) catch {};
}
fn readSocket(self: *Client) bool {
const n = self.ws.read() catch |err| {
log.warn(.app, "CDP read", .{ .err = err });
return false;
};
if (n == 0) {
log.info(.app, "CDP disconnect", .{});
return false;
}
return self.processData() catch false;
}
fn processData(self: *Client) !bool {
switch (self.mode) {
.cdp => |*cdp| return self.processWebsocketMessage(cdp),
.http => return self.processHTTPRequest(),
fn unregisterConn(self: *Server, conn: *CDP) void {
self.conns_mutex.lock();
defer self.conns_mutex.unlock();
for (self.conns.items, 0..) |c, i| {
if (c == conn) {
_ = self.conns.swapRemove(i);
break;
}
}
fn processHTTPRequest(self: *Client) !bool {
lp.assert(self.ws.reader.pos == 0, "Client.HTTP pos", .{ .pos = self.ws.reader.pos });
const request = self.ws.reader.buf[0..self.ws.reader.len];
if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) {
self.writeHTTPErrorResponse(413, "Request too large");
return error.RequestTooLarge;
}
// we're only expecting [body-less] GET requests.
if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) {
// we need more data, put any more data here
return true;
}
// the next incoming data can go to the front of our buffer
defer self.ws.reader.len = 0;
return self.handleHTTPRequest(request) catch |err| {
switch (err) {
error.NotFound => self.writeHTTPErrorResponse(404, "Not found"),
error.InvalidRequest => self.writeHTTPErrorResponse(400, "Invalid request"),
error.InvalidProtocol => self.writeHTTPErrorResponse(400, "Invalid HTTP protocol"),
error.MissingHeaders => self.writeHTTPErrorResponse(400, "Missing required header"),
error.InvalidUpgradeHeader => self.writeHTTPErrorResponse(400, "Unsupported upgrade type"),
error.InvalidVersionHeader => self.writeHTTPErrorResponse(400, "Invalid websocket version"),
error.InvalidConnectionHeader => self.writeHTTPErrorResponse(400, "Invalid connection header"),
else => {
log.err(.app, "server 500", .{ .err = err, .req = request[0..@min(100, request.len)] });
self.writeHTTPErrorResponse(500, "Internal Server Error");
},
}
return err;
};
}
fn handleHTTPRequest(self: *Client, request: []u8) !bool {
if (request.len < 18) {
// 18 is [generously] the smallest acceptable HTTP request
return error.InvalidRequest;
}
if (std.mem.eql(u8, request[0..4], "GET ") == false) {
return error.NotFound;
}
const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse {
return error.InvalidRequest;
};
const url = request[4..url_end];
if (std.mem.eql(u8, url, "/")) {
try self.upgradeConnection(request);
return true;
}
if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) {
try self.ws.send(self.ws.json_version_response);
// Chromedp (a Go driver) does an http request to /json/version
// then to / (websocket upgrade) using a different connection.
// Since we only allow 1 connection at a time, the 2nd one (the
// websocket upgrade) blocks until the first one times out.
// We can avoid that by closing the connection. json_version_response
// has a Connection: Close header too.
self.ws.shutdown();
return false;
}
if (std.mem.eql(u8, url, "/json/list") or std.mem.eql(u8, url, "/json/list/") or
std.mem.eql(u8, url, "/json") or std.mem.eql(u8, url, "/json/"))
{
try self.ws.send(empty_json_list_response);
self.ws.shutdown();
return false;
}
return error.NotFound;
}
fn upgradeConnection(self: *Client, request: []u8) !void {
try self.ws.upgrade(request);
self.mode = .{ .cdp = try CDP.init(self) };
}
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {
self.ws.sendHttpError(status, body);
}
fn processWebsocketMessage(self: *Client, cdp: *CDP) !bool {
return self.ws.processMessages(cdp);
}
pub fn sendAllocator(self: *Client) Allocator {
return self.ws.send_arena.allocator();
}
pub fn sendJSON(self: *Client, message: anytype, opts: std.json.Stringify.Options) !void {
return self.ws.sendJSON(message, opts);
}
pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void {
return self.ws.sendJSONRaw(buf);
}
};
}
// Utils
// --------
@@ -545,13 +271,6 @@ fn buildJSONVersionResponse(
return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port });
}
const empty_json_list_response =
"HTTP/1.1 200 OK\r\n" ++
"Content-Length: 2\r\n" ++
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
"[]";
pub const timestamp = @import("datetime.zig").timestamp;
pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
@@ -901,7 +620,7 @@ fn createTestClient() !TestClient {
const TestClient = struct {
stream: std.net.Stream,
buf: [1024]u8 = undefined,
reader: Net.Reader(false),
reader: WsConnection.Reader(false),
fn deinit(self: *TestClient) void {
self.stream.close();
@@ -968,7 +687,7 @@ const TestClient = struct {
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
}
fn readWebsocketMessage(self: *TestClient) !?Net.Message {
fn readWebsocketMessage(self: *TestClient) !?WsConnection.Message {
while (true) {
const n = try self.stream.read(self.reader.readBuf());
if (n == 0) {

View File

@@ -36,6 +36,7 @@ sigset: std.posix.sigset_t = undefined,
handle_thread: ?std.Thread = null,
attempt: u32 = 0,
mutex: std.Thread.Mutex = .{},
listeners: std.ArrayList(Listener) = .empty,
pub const Listener = struct {
@@ -96,10 +97,22 @@ pub fn on(self: *SigHandler, func: anytype, args: std.meta.ArgsTuple(@TypeOf(fun
const bytes: []const u8 = @ptrCast((&args)[0..1]);
@memcpy(buffer, bytes);
self.mutex.lock();
defer self.mutex.unlock();
try self.listeners.append(self.arena, .{
.args = buffer,
.start = TypeErased.start,
});
// If a termination signal arrived before this listener was registered,
// the sighandler thread had nothing to call. Fire the new listener now
// so the shutdown isn't lost — otherwise main proceeds into the network
// run loop and the process becomes an orphan that ignores the signal.
if (self.attempt > 0) {
const item = &self.listeners.items[self.listeners.items.len - 1];
item.start(item.args.ptr);
}
}
fn sighandle(self: *SigHandler) noreturn {
@@ -114,7 +127,9 @@ fn sighandle(self: *SigHandler) noreturn {
switch (sig) {
std.posix.SIG.INT, std.posix.SIG.TERM => {
self.mutex.lock();
if (self.attempt > 1) {
self.mutex.unlock();
std.process.exit(1);
}
self.attempt += 1;
@@ -123,12 +138,15 @@ fn sighandle(self: *SigHandler) noreturn {
for (self.listeners.items) |*item| {
item.start(item.args.ptr);
}
self.mutex.unlock();
continue;
},
std.posix.SIG.ALRM => {
// Deadline tripped (e.g. --terminate-ms). Run the same listeners,
// but don't bump `attempt` — a subsequent ctrl-c should still get
// the normal first-attempt graceful path before hard-exiting.
self.mutex.lock();
defer self.mutex.unlock();
log.info(.app, "Deadline reached ", .{});
for (self.listeners.items) |*item| {
item.start(item.args.ptr);

View File

@@ -3,7 +3,6 @@ const lp = @import("lightpanda");
const zenai = @import("zenai");
const App = @import("../App.zig");
const HttpClient = @import("../browser/HttpClient.zig");
const CDPNode = @import("../cdp/Node.zig");
const browser_tools = lp.tools;
@@ -11,7 +10,6 @@ const Self = @This();
allocator: std.mem.Allocator,
app: *App,
http_client: *HttpClient,
notification: *lp.Notification,
browser: lp.Browser,
session: *lp.Session,
@@ -19,29 +17,25 @@ node_registry: CDPNode.Registry,
tool_schema_arena: std.heap.ArenaAllocator,
pub fn init(allocator: std.mem.Allocator, app: *App) !*Self {
const http_client: *HttpClient = try .init(allocator, &app.network);
errdefer http_client.deinit();
const notification: *lp.Notification = try .init(allocator);
errdefer notification.deinit();
const self = try allocator.create(Self);
errdefer allocator.destroy(self);
var browser: lp.Browser = try .init(app, .{ .http_client = http_client });
errdefer browser.deinit();
self.* = .{
.allocator = allocator,
.app = app,
.http_client = http_client,
.notification = notification,
.browser = browser,
.browser = undefined,
.session = undefined,
.node_registry = CDPNode.Registry.init(allocator),
.tool_schema_arena = std.heap.ArenaAllocator.init(allocator),
};
try self.browser.init(app, .{}, null);
errdefer self.browser.deinit();
self.session = try self.browser.newSession(self.notification);
return self;
}
@@ -51,7 +45,6 @@ pub fn deinit(self: *Self) void {
self.node_registry.deinit();
self.browser.deinit();
self.notification.deinit();
self.http_client.deinit();
self.allocator.destroy(self);
}
@@ -60,7 +53,7 @@ pub fn deinit(self: *Self) void {
/// state that depended on the old session.
pub fn resetSession(self: *Self) !void {
self.browser.deinit();
self.browser = try lp.Browser.init(self.app, .{ .http_client = self.http_client });
try self.browser.init(self.app, .{}, null);
self.session = try self.browser.newSession(self.notification);
}

View File

@@ -27,6 +27,7 @@ const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool;
const Session = @import("Session.zig");
const Page = @import("Page.zig");
const Notification = @import("../Notification.zig");
// Browser is an instance of the browser.
@@ -39,32 +40,38 @@ app: *App,
session: ?Session,
allocator: Allocator,
arena_pool: *ArenaPool,
http_client: *HttpClient,
http_client: HttpClient,
// used by sessions to allocate pages.
page_pool: std.heap.MemoryPool(Page),
const InitOpts = struct {
env: js.Env.InitOpts = .{},
http_client: *HttpClient,
};
pub fn init(app: *App, opts: InitOpts) !Browser {
pub fn init(self: *Browser, app: *App, opts: InitOpts, cdp_client: ?HttpClient.CDPClient) !void {
const allocator = app.allocator;
var env = try js.Env.init(app, opts.env);
errdefer env.deinit();
return .{
self.* = .{
.app = app,
.env = env,
.session = null,
.allocator = allocator,
.arena_pool = &app.arena_pool,
.http_client = opts.http_client,
.http_client = undefined,
.page_pool = std.heap.MemoryPool(Page).init(allocator),
};
try self.http_client.init(allocator, &app.network, cdp_client);
}
pub fn deinit(self: *Browser) void {
self.closeSession();
self.env.deinit();
self.page_pool.deinit();
self.http_client.deinit();
}
pub fn newSession(self: *Browser, notification: *Notification) !*Session {

View File

@@ -327,7 +327,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void {
errdefer self._style_manager.deinit();
const browser = session.browser;
self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self);
self._script_manager = ScriptManager.init(browser.allocator, &browser.http_client, self);
errdefer self._script_manager.deinit();
self.js = try browser.env.createContext(self, .{
@@ -353,13 +353,9 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void {
}
}
pub fn deinit(self: *Frame, abort_http: bool) void {
pub fn deinit(self: *Frame) void {
for (self.child_frames.items) |frame| {
frame.deinit(abort_http);
}
for (self.workers.items) |worker| {
worker.deinit();
frame.deinit();
}
if (comptime IS_DEBUG) {
@@ -413,16 +409,16 @@ pub fn deinit(self: *Frame, abort_http: bool) void {
const browser = page.session.browser;
browser.env.destroyContext(self.js);
// Must be after context is destroyed. A finalizer can reach into the *Worker
// (e.g. Worker.ReceiveMessageCallback) so the worker must still be valid.
for (self.workers.items) |worker| {
worker.deinit();
}
self._script_manager.base.shutdown = true;
if (self.parent == null) {
browser.http_client.abort();
} else if (abort_http) {
// a small optimization, it's faster to abort _everything_ on the root
// frame, so we prefer that. But if it's just the frame that's going
// away (a frame navigation) then we'll abort the frame-related requests
browser.http_client.abortFrame(self._frame_id);
}
// don't abort pending frames.
browser.http_client.abortFrame(self._frame_id, .{});
self._script_manager.deinit();
self._style_manager.deinit();
@@ -613,7 +609,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
return;
}
var http_client = session.browser.http_client;
const http_client = &session.browser.http_client;
self.url = try self.arena.dupeZ(u8, request_url);
self.origin = try URL.getOrigin(self.arena, self.url);
@@ -635,6 +631,15 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
const ref_header = try std.mem.concatWithSentinel(self.arena, u8, &.{ "Referer: ", ref }, 0);
try headers.add(ref_header);
}
// A root navigation issued against a pending Page (i.e. one allocated by
// Session.initiateRootNavigation) flags both the notification and the
// HTTP request itself: CDP skips its node-registry reset until commit,
// and the in-flight transfer survives the OLD page's frame.deinit which
// calls http_client.abortFrame(frame_id) on the shared frame_id during
// commitPendingPage.
const is_pending_root = self._page._state == .pending;
// We dispatch frame_navigate event before sending the request.
// It ensures the event frame_navigated is not dispatched before this one.
session.notification.dispatch(.frame_navigate, &.{
@@ -644,6 +649,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
.frame_id = self._frame_id,
.loader_id = self._loader_id,
.timestamp = timestamp(.monotonic),
.is_pending_root = is_pending_root,
});
// Record telemetry for navigation
@@ -667,6 +673,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
.cookie_origin = self.url,
.resource_type = .document,
.notification = self._session.notification,
.protect_from_abort = is_pending_root,
},
.header_callback = frameHeaderDoneCallback,
.data_callback = frameDataCallback,
@@ -757,17 +764,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url
.type = target._type,
});
// This is a micro-optimization. Terminate any inflight request as early
// as we can. This will be more properly shutdown when we process the
// scheduled navigation.
if (target.parent == null) {
session.browser.http_client.abort();
} else {
// This doesn't terminate any inflight requests for nested frames, but
// again, this is just an optimization. We'll correctly shut down all
// nested inflight requests when we process the navigation.
session.browser.http_client.abortFrame(target._frame_id);
}
session.browser.http_client.abortFrame(target._frame_id, .{});
// Capture the originating frame's URL as the Referer for this
// navigation. The originator's frame may be torn down before navigate()
@@ -972,6 +969,28 @@ fn notifyParentLoadComplete(self: *Frame) void {
fn frameHeaderDoneCallback(response: HttpClient.Response) !bool {
var self: *Frame = @ptrCast(@alignCast(response.ctx));
// Commit point for a pending root navigation. The session has been
// holding the OLD page alive during the round-trip; now that response
// headers have arrived, swap pending → active. This dispatches
// frame_remove (clears OLD V8 context group + CDP node_registry),
// tears down the OLD page, flips the pointer, and dispatches
// frame_created against the new (now active) frame.
//
// The OLD page's frame.deinit calls http_client.abortFrame(frame_id) on
// the frame_id it shares with the (now-active) pending page; our transfer
// survives because Session.initiateRootNavigation flagged the request
// protect_from_abort, which abortFrame's default .normal scope honors.
// Once we are past commit, that protection is no longer needed and may
// interfere with subsequent aborts (e.g. another navigation while we are
// still streaming the body), so clear it.
if (self._page._state == .pending) {
try self._session.commitPendingPage();
switch (response.inner) {
.transfer => |t| t.req.params.protect_from_abort = false,
.fulfilled, .cached => {},
}
}
const response_url = response.url();
if (std.mem.eql(u8, response_url, self.url) == false) {
// would be different than self.url in the case of a redirect
@@ -1201,6 +1220,16 @@ fn frameErrorCallback(ctx: *anyopaque, err: anyerror) void {
var self: *Frame = @ptrCast(@alignCast(ctx));
log.err(.frame, "navigate failed", .{ .err = err, .type = self._type, .url = self.url });
// A pending root navigation that failed before commit: discard the
// pending Page; the OLD active Page (and its V8 context) is untouched.
// We do NOT run frameDoneCallback against the pending frame — the frame
// is about to be freed.
if (self._page._state == .pending) {
self._session.discardPendingPage();
return;
}
self._parse_state.deinit(self);
self._parse_state = .{ .err = err };
@@ -1276,7 +1305,7 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
const frame_id = session.nextFrameId();
try Frame.init(new_frame, frame_id, self._page, self);
errdefer new_frame.deinit(true);
errdefer new_frame.deinit();
self._pending_loads += 1;
new_frame.iframe = iframe;
@@ -1385,7 +1414,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
const frame_id = session.nextFrameId();
try Frame.init(popup, frame_id, page, null);
errdefer popup.deinit(true);
errdefer popup.deinit();
popup.window._opener = opts.opener;
if (opts.name.len > 0 and
@@ -2854,6 +2883,10 @@ pub fn dispatch(
return self._event_manager.dispatchDirect(target, event, handler, opts);
}
pub fn hasDirectListeners(self: *Frame, target: *EventTarget, typ: []const u8, handler: anytype) bool {
return self._event_manager.hasDirectListeners(target, typ, handler);
}
pub fn dupeSSO(self: *Frame, value: []const u8) !String {
return String.init(self.arena, value, .{ .dupe = true });
}

View File

@@ -54,9 +54,9 @@ pub const InterceptionLayer = @import("../network/layer/InterceptionLayer.zig");
//
// The app has other secondary http needs, like telemetry. While we want to
// share some things (namely the ca blob, and maybe some configuration
// (TODO: ??? should proxy settings be global ???)), we're able to do call
// client.abort() to abort the transfers being made by a frame, without impacting
// those other http requests.
// (TODO: ??? should proxy settings be global ???)), we're able to call
// client.abortFrame() to abort the transfers being made by a frame, without
// impacting those other http requests.
pub const Client = @This();
// Count of active ws requests
@@ -166,23 +166,21 @@ pub const CDPClient = struct {
blocking_read_end: *const fn (*anyopaque) bool,
};
pub fn init(allocator: Allocator, network: *Network) !*Client {
pub fn init(self: *Client, allocator: Allocator, network: *Network, cdp_client: ?CDPClient) !void {
var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator);
errdefer transfer_pool.deinit();
const client = try allocator.create(Client);
errdefer allocator.destroy(client);
var handles = try http.Handles.init(network.config);
errdefer handles.deinit();
const http_proxy = network.config.httpProxy();
client.* = .{
self.* = Client{
.handles = handles,
.network = network,
.allocator = allocator,
.transfer_pool = transfer_pool,
.cdp_client = cdp_client,
.use_proxy = http_proxy != null,
.http_proxy = http_proxy,
@@ -197,25 +195,23 @@ pub fn init(allocator: Allocator, network: *Network) !*Client {
.entry_layer = undefined,
};
var next = client.layer();
var next = self.layer();
if (network.config.obeyRobots()) {
next = layerWith(&client.robots_layer, next);
next = layerWith(&self.robots_layer, next);
}
if (network.config.httpCacheDir() != null) {
next = layerWith(&client.cache_layer, next);
next = layerWith(&self.cache_layer, next);
}
next = layerWith(&client.interception_layer, next);
next = layerWith(&self.interception_layer, next);
if (network.config.webBotAuth() != null) {
next = layerWith(&client.web_bot_auth_layer, next);
next = layerWith(&self.web_bot_auth_layer, next);
}
client.entry_layer = next;
return client;
self.entry_layer = next;
}
pub fn deinit(self: *Client) void {
@@ -226,8 +222,6 @@ pub fn deinit(self: *Client) void {
self.clearUserAgentOverride();
self.robots_layer.deinit(self.allocator);
self.allocator.destroy(self);
}
pub fn layer(self: *Client) Layer {
@@ -304,19 +298,25 @@ pub fn getUserAgent(self: *const Client) [:0]const u8 {
return self.user_agent_override orelse self.network.config.http_headers.user_agent;
}
const AbortOpts = struct {
scope: enum { normal, full } = .normal,
};
pub fn abort(self: *Client) void {
self._abort(true, 0);
self._abort(true, 0, .{ .scope = .full });
}
pub fn abortFrame(self: *Client, frame_id: u32) void {
self._abort(false, frame_id);
// abortFrame with .normal doesn't abort protect_from_abort requests.
// .full abort all relqtive requests.
pub fn abortFrame(self: *Client, frame_id: u32, opts: AbortOpts) void {
self._abort(false, frame_id, opts);
}
// Written this way so that both abort and abortFrame can share the same code
// but abort can avoid the frame_id check at comptime.
fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
abortConnections(self.in_use, abort_all, frame_id);
abortConnections(self.ready_queue, abort_all, frame_id);
fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32, opts: AbortOpts) void {
abortConnections(self.in_use, abort_all, frame_id, opts);
abortConnections(self.ready_queue, abort_all, frame_id, opts);
{
var q = &self.queue;
@@ -324,11 +324,14 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
while (n) |node| {
n = node.next;
const transfer: *Transfer = @fieldParentPtr("_node", node);
const params = transfer.req.params;
if (comptime abort_all) {
transfer.kill();
} else if (transfer.req.params.frame_id == frame_id) {
q.remove(node);
transfer.kill();
} else if (params.frame_id == frame_id) {
if (opts.scope == .full or !params.protect_from_abort) {
q.remove(node);
transfer.kill();
}
}
}
}
@@ -339,8 +342,6 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
}
if (comptime IS_DEBUG and abort_all) {
// Even after an abort_all, we could still have transfers, but, at the
// very least, they should all be flagged as aborted.
var it = self.in_use.first;
var leftover: usize = 0;
while (it) |node| : (it = node.next) {
@@ -356,15 +357,20 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
}
}
fn abortConnections(list: std.DoublyLinkedList, comptime abort_all: bool, frame_id: u32) void {
fn abortConnections(list: std.DoublyLinkedList, comptime abort_all: bool, frame_id: u32, opts: AbortOpts) void {
var n = list.first;
while (n) |node| {
n = node.next;
const conn: *http.Connection = @fieldParentPtr("node", node);
switch (conn.transport) {
.http => |transfer| {
if ((comptime abort_all) or transfer.req.params.frame_id == frame_id) {
const params = transfer.req.params;
if (comptime abort_all) {
transfer.kill();
} else if (params.frame_id == frame_id) {
if (opts.scope == .full or !params.protect_from_abort) {
transfer.kill();
}
}
},
.websocket => |ws| {
@@ -878,6 +884,15 @@ pub const RequestParams = struct {
notification: *Notification,
timeout_ms: u32 = 0,
// Set on an in-flight root-navigation transfer that was issued against a
// pending Page. The old Page's frame.deinit (called from Session.commit
// PendingPage when response headers arrive) calls abortFrame() on the
// shared frame_id; abortFrame's default .normal scope skips transfers
// with this flag so the callback chain we are sitting inside isn't killed
// mid-flight. Session.discardPendingPage uses .full scope to override
// the flag in failure paths.
protect_from_abort: bool = false,
const ResourceType = enum {
document,
xhr,

View File

@@ -102,6 +102,16 @@ popups: std.ArrayList(*Frame) = .empty,
// from a script eval whose parser still holds the Frame).
queued_close: std.ArrayList(*Frame) = .empty,
// Lifecycle state. A Page is `.pending` while we hold it as the in-flight
// destination of a root navigation — its V8 context exists but is not yet the
// session's active context. Flipped to `.active` by Session.commitPendingPage
// when response headers arrive. Frame.navigate / frameHeaderDoneCallback
// branch on this to: (a) stamp `is_pending_root` on the frame_navigate
// notification (so CDP doesn't reset its node registry yet) and
// (b) flag the HTTP request `protect_from_abort` (so the old page's deinit
// can't kill the transfer we're sitting inside).
_state: enum { active, pending } = .active,
// Initialize a Page and its root Frame.
pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena");
@@ -120,15 +130,15 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
// Tear down the Page and its root Frame. Equivalent to the old
// Session.removePage + Session.resetFrameResources.
pub fn deinit(self: *Page, abort_http: bool) void {
pub fn deinit(self: *Page) void {
self.cleanupClosedPopups();
for (self.popups.items) |popup| {
popup.deinit(abort_http);
popup.deinit();
}
self.popups = .empty;
self.frame.deinit(abort_http);
self.frame.deinit();
const session = self.session;
defer session.browser.env.memoryPressureNotification(.moderate);
@@ -178,7 +188,7 @@ pub fn deinit(self: *Page, abort_http: bool) void {
pub fn cleanupClosedPopups(self: *Page) void {
for (self.queued_close.items) |popup| {
popup.deinit(true);
popup.deinit();
}
self.queued_close = .empty;
}

View File

@@ -45,7 +45,7 @@ pub fn init(session: *Session, _: Opts) !Runner {
return .{
.frame = frame,
.session = session,
.http_client = session.browser.http_client,
.http_client = &session.browser.http_client,
};
}
@@ -142,6 +142,10 @@ pub fn tickCDP(self: *Runner, opts: TickOpts) !CDPTickResult {
}
fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
// Refresh self.frame from session. In case of pending page, we want to
// take its state while loading. If we use only the current frame, we will
// return a .done result immediately.
self.frame = self.session.pendingOrCurrentFrame() orelse return .done;
const frame = self.frame;
const http_client = self.http_client;

View File

@@ -648,12 +648,19 @@ pub const Script = struct {
pub fn errorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *Script = @ptrCast(@alignCast(ctx));
log.warn(.http, "script fetch error", .{
.err = err,
.req = self.url,
.extra = std.meta.activeTag(self.extra),
.status = self.status,
});
if (self.status == 404) {
log.info(.http, "script 404", .{
.req = self.url,
.extra = std.meta.activeTag(self.extra),
});
} else {
log.warn(.http, "script fetch error", .{
.err = err,
.req = self.url,
.extra = std.meta.activeTag(self.extra),
.status = self.status,
});
}
if (self.extra == .frame and self.extra.frame.mode == .normal) {
// This is blocked in a loop at the end of addFromElement, setting

View File

@@ -65,9 +65,12 @@ arena_pool: *ArenaPool,
// teardowns so V8 weak callbacks can validate the FC before dereferencing it.
fc_identity_pool: std.heap.MemoryPool(FinalizerCallback.Identity),
// The currently-active Page. Null when no Page exists (between removePage
// and createPage, or at startup).
page: ?Page,
// The currently-active Page
// flips this pointer.
_active: ?*Page = null,
// In-flight root navigation
_pending: ?*Page = null,
// IDs. Kept at Session level so IDs can remain unique across Page replacements.
frame_id_gen: u32 = 0,
@@ -81,7 +84,6 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
errdefer arena_pool.release(arena);
self.* = .{
.page = null,
.arena = arena,
.arena_pool = arena_pool,
.history = .{},
@@ -96,7 +98,10 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
}
pub fn deinit(self: *Session) void {
if (self.page != null) {
if (self._pending != null) {
self.discardPendingPage();
}
if (self._active != null) {
self.removePage();
}
self.cookie_jar.deinit();
@@ -106,52 +111,103 @@ pub fn deinit(self: *Session) void {
self.arena_pool.release(self.arena);
}
// NOTE: the caller is not the owner of the returned value,
// the pointer on Frame is just returned as a convenience
pub fn createPage(self: *Session) !*Frame {
lp.assert(self.page == null, "Session.createPage - page not null", .{});
// True iff there is an active Page. CDP / external callers should use this
// (or `currentPage()`) rather than poking at the underlying field.
pub fn hasPage(self: *const Session) bool {
return self._active != null;
}
self.page = @as(Page, undefined);
const page = &self.page.?;
// Allocate and initialize a Page.
fn allocatePage(self: *Session, frame_id: u32) !*Page {
const page = try self.browser.page_pool.create();
errdefer self.browser.page_pool.destroy(page);
errdefer self.page = null;
try Page.init(page, self, frame_id);
return page;
}
// Tear down and free a Page allocated via allocatePage.
fn destroyPage(self: *Session, page: *Page) void {
page.deinit();
self.browser.page_pool.destroy(page);
}
// Tear down the currently-active Page. Dispatches `frame_remove` first
// so CDP can clear inspector state while the OLD page is still walkable,
// then frees the slot and notifies Navigation. Resets `frame_id_gen` to
// match pre-pending-page behavior. Used by removePage and by the
// synthetic-nav path (replaceRootImmediate). Does NOT touch any pending
// page — callers handle that themselves.
//
// NOT a substitute for the careful 5-step sequence in commitPendingPage,
// which interleaves the OLD-page teardown with the pending-page promotion
// in a specific order.
fn tearDownActivePage(self: *Session) void {
self.notification.dispatch(.frame_remove, .{});
const page = self._active orelse {
if (comptime IS_DEBUG) {
lp.assert(false, "Session.tearDownActivePage - no active page", .{});
}
return;
};
self.destroyPage(page);
self._active = null;
self.navigation.onRemoveFrame();
self.frame_id_gen = 0;
}
// Allocate a Page in a free slot, publish it as the active page, and
// dispatch `frame_created` so CDP creates fresh isolated-world V8
// contexts. Used by createPage and by the synthetic-nav path. Does NOT
// dispatch `frame_navigate` — the caller does that (or doesn't, for a
// blank initial page).
//
// On any failure after allocation, the errdefers roll back the Page
// and `active`, leaving the session pageless (the caller is responsible
// for any prior teardown of an old page).
fn installNewActivePage(self: *Session, frame_id: u32) !*Frame {
const page = try self.allocatePage(frame_id);
errdefer self.destroyPage(page);
self._active = page;
errdefer self._active = null;
try Page.init(page, self, self.nextFrameId());
const frame = &page.frame;
// Creates a new NavigationEventTarget for this frame.
try self.navigation.onNewFrame(frame);
if (comptime IS_DEBUG) {
log.debug(.browser, "create page", .{});
}
// start JS env
// Inform CDP the main frame has been created such that additional context for other Worlds can be created as well
// Inform CDP the main frame has been created such that additional
// context for other Worlds can be created as well.
self.notification.dispatch(.frame_created, frame);
return frame;
}
// NOTE: the caller is not the owner of the returned value,
// the pointer on Frame is just returned as a convenience
pub fn createPage(self: *Session) !*Frame {
lp.assert(self._active == null, "Session.createPage - page not null", .{});
if (comptime IS_DEBUG) {
log.debug(.browser, "create page", .{});
}
return self.installNewActivePage(self.nextFrameId());
}
pub fn removePage(self: *Session) void {
lp.assert(self.page != null, "Session.removePage - page is null", .{});
if (self.page.?.frame._script_manager.base.is_evaluating) {
const page = self._active orelse {
lp.assert(false, "Session.removePage - page is null", .{});
};
if (page.frame._script_manager.base.is_evaluating) {
// Reentrant teardown from a CDP message drained inside syncRequest;
// Session.deinit reclaims the page when the connection closes.
return;
}
// Inform CDP the frame is going to be removed, allowing other worlds to remove themselves before the main one
self.notification.dispatch(.frame_remove, .{});
self.page.?.deinit(false);
self.page = null;
self.navigation.onRemoveFrame();
// resetting frame_id_gen preserves previous behavior where removing the
// root page returned us to a clean-slate state.
self.frame_id_gen = 0;
// If a navigation is in flight, drop the pending Page first. Its
// transfer was protected from abort to survive commitPendingPage's
// teardown of the old page, but we are now permanently removing the
// session's page state — the pending transfer should die with it.
if (self._pending != null) {
self.discardPendingPage();
}
self.tearDownActivePage();
if (comptime IS_DEBUG) {
log.debug(.browser, "remove page", .{});
}
@@ -166,42 +222,24 @@ pub fn releaseArena(self: *Session, allocator: Allocator) void {
}
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
return self.page.?.getOrCreateOrigin(key_);
return self.currentPage().?.getOrCreateOrigin(key_);
}
pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
return self.page.?.releaseOrigin(origin);
}
pub fn replacePage(self: *Session) !*Frame {
if (comptime IS_DEBUG) {
log.debug(.browser, "replace page", .{});
}
lp.assert(self.page != null, "Session.replacePage null page", .{});
const current = &self.page.?;
lp.assert(current.frame.parent == null, "Session.replacePage with parent", .{});
const frame_id = current.frame._frame_id;
current.deinit(true);
self.page = null;
// Preserve prior behavior: frame_id_gen reset on root replacement so a
// subsequent createPage starts from id 1. The captured frame_id is
// passed into Page.init explicitly, so it isn't affected.
self.frame_id_gen = 0;
self.page = @as(Page, undefined);
const page = &self.page.?;
errdefer self.page = null;
try Page.init(page, self, frame_id);
return &page.frame;
self.currentPage().?.releaseOrigin(origin);
}
pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null);
return self._active;
}
pub fn pendingPage(self: *Session) ?*Page {
return self._pending;
}
pub fn pendingOrCurrentFrame(self: *Session) ?*Frame {
const page = self.pendingPage() orelse self.currentPage() orelse return null;
return &page.frame;
}
pub fn currentFrame(self: *Session) ?*Frame {
@@ -219,7 +257,7 @@ pub fn runner(self: *Session, opts: Runner.Opts) !Runner {
}
pub fn scheduleNavigation(self: *Session, frame: *Frame) !void {
return self.page.?.scheduleNavigation(frame);
return self.currentPage().?.scheduleNavigation(frame);
}
pub fn processQueuedNavigation(self: *Session) !void {
@@ -318,7 +356,7 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
const frame_id = frame._frame_id;
const page = self.currentPage().?;
frame.deinit(true);
frame.deinit();
frame.* = undefined;
errdefer {
@@ -338,7 +376,7 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
if (parent_notified) {
parent._pending_loads -= 1;
}
frame.deinit(true);
frame.deinit();
}
frame.iframe = iframe;
@@ -364,7 +402,7 @@ fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
const frame_id = frame._frame_id;
const page = self.currentPage().?;
frame.deinit(true);
frame.deinit();
frame.* = undefined;
errdefer {
@@ -378,7 +416,7 @@ fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
}
try Frame.init(frame, frame_id, page, null);
errdefer frame.deinit(true);
errdefer frame.deinit();
frame.window._name = saved_name;
frame.window._opener = saved_opener;
@@ -390,42 +428,43 @@ fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
}
fn processRootQueuedNavigation(self: *Session) !void {
const current_frame = &self.page.?.frame;
const frame_id = current_frame._frame_id;
const active = self._active orelse {
lp.assert(false, "Session.processRootQueuedNavigation - no active page", .{});
};
const current_frame = &active.frame;
// create a copy before the frame is cleared
// Detach the QueuedNavigation. Whether we keep it on the active frame
// (synthetic path) or transfer it to the pending frame (HTTP path), the
// current frame must no longer claim it.
const qn = current_frame._queued_navigation.?;
current_frame._queued_navigation = null;
// Synthetic navigations (about:blank, blob:) commit instantly — no HTTP,
// so there is no in-flight window to worry about. Use the optimized
// immediate-swap path for them.
const is_synthetic = qn.is_about_blank or std.mem.startsWith(u8, qn.url, "blob:");
if (is_synthetic) {
return self.replaceRootImmediate(current_frame._frame_id, qn);
}
// The qn arena is consumed here regardless of success — frame.navigate
// dupes the URL into the page's own arena, so we can release the qn
// arena as soon as navigate returns.
defer self.arena_pool.release(qn.arena);
// Dispatch frame_remove (same as removePage) then replace the Page
// in-place, keeping the frame_id stable.
self.notification.dispatch(.frame_remove, .{});
self.page.?.deinit(true);
self.page = null;
return self.initiateRootNavigation(current_frame._frame_id, qn.url, qn.opts);
}
self.navigation.onRemoveFrame();
// Legacy immediate-swap path: tear down the active page and create a new one
// in its place before issuing the navigation. Used for synthetic navigations
// (about:blank, blob:) where there is no in-flight HTTP and therefore no
// "pending" window to span.
fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !void {
defer self.arena_pool.release(qn.arena);
// Preserve prior behavior: the old resetFrameResources reset frame_id_gen.
self.frame_id_gen = 0;
self.page = @as(Page, undefined);
const page = &self.page.?;
errdefer self.page = null;
try Page.init(page, self, frame_id);
const new_frame = &page.frame;
// Creates a new NavigationEventTarget for this frame.
self.navigation.onNewFrame(new_frame) catch |err| {
log.err(.browser, "createPage onNewNewFrame", .{ .err = err });
};
// start JS env
// Inform CDP the main frame has been created such that additional context for other Worlds can be created as well
self.notification.dispatch(.frame_created, new_frame);
self.tearDownActivePage();
const new_frame = try self.installNewActivePage(frame_id);
new_frame.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err });
@@ -433,6 +472,121 @@ fn processRootQueuedNavigation(self: *Session) !void {
};
}
// Real HTTP root navigation: allocate a pending Page, leave the active Page
// alive, and dispatch the navigation HTTP request against the pending frame.
// The active Page (and its V8 context) stays addressable across the round-
// trip — Runtime.evaluate, DOM.*, etc. continue to operate on the OLD page
// until commitPendingPage swaps the pointer when response headers arrive.
pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, opts: Frame.NavigateOpts) !void {
self.discardPendingPage();
const page = try self.allocatePage(frame_id);
errdefer self.destroyPage(page);
page._state = .pending;
self._pending = page;
errdefer self._pending = null;
if (comptime IS_DEBUG) {
log.debug(.browser, "initiate root navigation", .{ .url = url });
}
// No frame_created notification yet — CDP must not see the pending page
// (no isolated worlds, no Target.* visibility). Both the pending main
// world and the isolated worlds get registered with the V8 inspector at
// commit, after frame_remove tears down the OLD page's context group.
page.frame.navigate(url, opts) catch |err| {
log.err(.browser, "pending navigation start", .{ .err = err, .url = url });
return err;
};
}
// Promote the pending Page to be the active Page. Called from
// frameHeaderDoneCallback when the in-flight pending root navigation's
// response headers arrive.
//
// Order matters here:
// 1. frame_remove dispatch — CDP's frameRemove resets the V8 inspector
// context group (emits Runtime.executionContextsCleared) and clears
// isolated world contexts plus the node_registry. The OLD page's
// memory is still alive at this point (intentional: CDP teardown can
// walk old-page state without UAF).
// 2. Pointer flip and _state = .active. session.page now points at the
// pending page.
// 3. frame_created dispatch — CDP creates fresh isolated world contexts
// against the new (now active) frame. While pending_page is still
// non-null at this point, CDP's frameCreated handler skips its
// frame_arena reset and captured_responses zeroing (the captured_
// response for the request we are committing was just inserted by
// onHttpResponseHeadersDone moments earlier and must survive).
// 4. pending_page = null. Order matters: step 3 reads it.
// 5. OLD Page.deinit + free LAST. Its frame.deinit calls
// http_client.abortFrame(frame_id) on the frame_id that the OLD
// page shares with the now-active pending page; the in-flight
// navigation transfer (whose callback we are inside) is shielded
// by protect_from_abort, which abortFrame's default .normal scope
// honors. The caller clears the flag AFTER we return.
pub fn commitPendingPage(self: *Session) !void {
const pending = self._pending orelse {
lp.assert(false, "Session.commitPendingPage - no pending page", .{});
};
const old_active = self._active orelse {
lp.assert(false, "Session.commitPendingPage - no active page", .{});
};
if (comptime IS_DEBUG) {
log.debug(.browser, "commit pending page", .{});
}
// Step 1: clear the OLD page's CDP / V8 inspector state.
self.notification.dispatch(.frame_remove, .{});
self.navigation.onRemoveFrame();
// Step 2: pointer flip. Page addresses are stable (heap-allocated),
// so every self-pointer inside `pending` (window._frame,
// document._frame, EventManager.frame, etc.) remains valid.
self._active = pending;
pending._state = .active;
// Step 3: register the new page with CDP. `pending` is still set at
// this point — CDP's frameCreated handler reads `pendingPage() != null`
// to skip the captured_responses / frame_arena resets that would wipe
// the in-flight response we just received.
self.navigation.onNewFrame(&pending.frame) catch |err| {
log.err(.browser, "commitPendingPage onNewFrame", .{ .err = err });
};
self.notification.dispatch(.frame_created, &pending.frame);
// Step 4: `pending` = null AFTER frame_created so step 3 saw it.
self._pending = null;
// Step 5: tear down the OLD page LAST. Anything in steps 1-4 that
// needed to walk the OLD page's state (CDP node_registry, inspector
// context group, isolated worlds) has already done so. The OLD page's
// frame.deinit calls http_client.abortFrame(frame_id) on the frame_id
// shared with the pending page; the in-flight transfer survives via
// protect_from_abort.
self.destroyPage(old_active);
}
// Discard a pending Page without committing. Used for failure paths
// (HTTP error before commit, session deinit during pending, etc.). The
// active page is untouched.
pub fn discardPendingPage(self: *Session) void {
const page = self._pending orelse return;
if (comptime IS_DEBUG) {
log.debug(.browser, "discard pending page", .{});
}
// Force abort all inflight queries.
self.browser.http_client.abortFrame(page.frame._frame_id, .{ .scope = .full });
self._pending = null;
self.destroyPage(page);
}
pub fn nextFrameId(self: *Session) u32 {
const id = self.frame_id_gen +% 1;
self.frame_id_gen = id;

View File

@@ -31,6 +31,12 @@ const lp = @import("lightpanda");
const Context = @import("Context.zig");
const Scheduler = @import("Scheduler.zig");
const Factory = @import("../Factory.zig");
const HttpClient = @import("../HttpClient.zig");
const EventManagerBase = @import("../EventManagerBase.zig");
const Blob = @import("../webapi/Blob.zig");
const Event = @import("../webapi/Event.zig");
const EventTarget = @import("../webapi/EventTarget.zig");
const String = lp.String;
const Allocator = std.mem.Allocator;
@@ -63,3 +69,59 @@ pub fn dupeString(self: *const Execution, value: []const u8) ![]const u8 {
}
return self.arena.dupe(u8, value);
}
pub fn getArena(self: *const Execution, size_or_bucket: anytype, debug: []const u8) !Allocator {
return self.context.page.getArena(size_or_bucket, debug);
}
pub fn releaseArena(self: *const Execution, allocator: Allocator) void {
self.context.page.releaseArena(allocator);
}
pub fn headersForRequest(self: *const Execution, headers: *HttpClient.Headers) !void {
return switch (self.context.global) {
inline else => |g| g.headersForRequest(headers),
};
}
pub fn isSameOrigin(self: *const Execution, url: [:0]const u8) bool {
return switch (self.context.global) {
inline else => |g| g.isSameOrigin(url),
};
}
pub fn lookupBlobUrl(self: *const Execution, url: []const u8) ?*Blob {
return switch (self.context.global) {
inline else => |g| g.lookupBlobUrl(url),
};
}
pub fn dispatch(
self: *const Execution,
target: *EventTarget,
event: *Event,
handler: anytype,
comptime opts: EventManagerBase.DispatchDirectOptions,
) !void {
return switch (self.context.global) {
inline else => |g| g.dispatch(target, event, handler, opts),
};
}
pub fn hasDirectListeners(self: *const Execution, target: *EventTarget, typ: []const u8, handler: anytype) bool {
return switch (self.context.global) {
inline else => |g| g.hasDirectListeners(target, typ, handler),
};
}
pub fn frameId(self: *const Execution) u32 {
return switch (self.context.global) {
inline else => |g| g._frame_id,
};
}
pub fn loaderId(self: *const Execution) u32 {
return switch (self.context.global) {
inline else => |g| g._loader_id,
};
}

71
src/browser/js/RegExp.zig Normal file
View File

@@ -0,0 +1,71 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const RegExp = @This();
local: *const js.Local,
handle: *const v8.RegExp,
// Mirrors v8::RegExp::Flags. Combine with bitwise OR.
pub const Flag = struct {
pub const none: c_int = v8.kRegExpNone;
pub const global: c_int = v8.kRegExpGlobal;
pub const ignore_case: c_int = v8.kRegExpIgnoreCase;
pub const multiline: c_int = v8.kRegExpMultiline;
pub const sticky: c_int = v8.kRegExpSticky;
pub const unicode: c_int = v8.kRegExpUnicode;
pub const dot_all: c_int = v8.kRegExpDotAll;
pub const linear: c_int = v8.kRegExpLinear;
pub const has_inSelfdices: c_int = v8.kRegExpHasIndices;
pub const unicode_sets: c_int = v8.kRegExpUnicodeSets;
};
pub fn init(local: *const js.Local, pattern: []const u8, flags: c_int) !RegExp {
const pattern_handle = local.isolate.initStringHandle(pattern);
const handle = v8.v8__RegExp__New(local.handle, pattern_handle, flags) orelse return error.JsException;
return .{ .local = local, .handle = handle };
}
// Runs the pattern against `subject`. Returns the result Array (as a generic
// Object) on match, or null on no match. Returns error.JsException if V8
// throws — typically when the pattern is malformed for the current flags.
pub fn exec(self: RegExp, subject: []const u8) !?js.Object {
const local = self.local;
const subject_handle = local.isolate.initStringHandle(subject);
const handle = v8.v8__RegExp__Exec(self.handle, local.handle, subject_handle) orelse return error.JsException;
if (v8.v8__Value__IsNullOrUndefined(@ptrCast(handle))) return null;
return .{ .local = local, .handle = handle };
}
// Equivalent to `RegExp.prototype.test()` — true iff the pattern matches.
pub fn match(self: RegExp, subject: []const u8) !bool {
return (try self.exec(subject)) != null;
}
pub fn toValue(self: RegExp) js.Value {
return .{
.local = self.local,
.handle = @ptrCast(self.handle),
};
}

View File

@@ -966,6 +966,9 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/AbortController.zig"),
@import("../webapi/URL.zig"),
@import("../webapi/canvas/OffscreenCanvas.zig"),
@import("../webapi/net/XMLHttpRequest.zig"),
@import("../webapi/net/XMLHttpRequestEventTarget.zig"),
@import("../webapi/FileReader.zig"),
// @import("../webapi/Performance.zig"),
});

View File

@@ -42,6 +42,7 @@ pub const Object = @import("Object.zig");
pub const TryCatch = @import("TryCatch.zig");
pub const Function = @import("Function.zig");
pub const Promise = @import("Promise.zig");
pub const RegExp = @import("RegExp.zig");
pub const Module = @import("Module.zig");
pub const BigInt = @import("BigInt.zig");
pub const Number = @import("Number.zig");

View File

@@ -17,6 +17,13 @@
<input id="disabled-required" type="text" required disabled>
<input id="too-long" type="text" maxlength="3">
<input id="too-short" type="text" minlength="3" value="ab">
<input id="zip-bad" type="text" pattern="[0-9]{5,9}" value="1234">
<input id="zip-good" type="text" pattern="[0-9]{5,9}" value="12345">
<input id="zip-empty" type="text" pattern="[0-9]{5,9}">
<input id="pat-anchored" type="text" pattern="abc" value="xabcy">
<input id="pat-unicode" type="text" pattern="\p{L}+" value="hello">
<input id="pat-bad-regex" type="text" pattern="(unclosed" value="anything">
<input id="pat-num" type="number" pattern="[0-9]+" value="x">
</form>
<script id="surface">
@@ -189,3 +196,86 @@
testing.expectEqual(true, captured.isTrusted);
}
</script>
<script id="pattern_idl_reflects_attribute">
{
// Per HTML §4.10.5.3.5, HTMLInputElement.pattern reflects the content attribute.
testing.expectEqual('[0-9]{5,9}', $('#zip-bad').pattern);
testing.expectEqual('[0-9]{5,9}', $('#zip-good').pattern);
testing.expectEqual('', $('#filled-required').pattern);
// Setter writes the content attribute.
const el = $('#filled-required');
el.pattern = 'foo';
testing.expectEqual('foo', el.getAttribute('pattern'));
testing.expectEqual('foo', el.pattern);
el.pattern = '';
testing.expectEqual('', el.getAttribute('pattern'));
}
</script>
<script id="pattern_mismatch_basic">
{
// "1234" does not match [0-9]{5,9} — patternMismatch fires.
testing.expectEqual(true, $('#zip-bad').validity.patternMismatch);
testing.expectEqual(false, $('#zip-bad').validity.valid);
// "12345" does match.
testing.expectEqual(false, $('#zip-good').validity.patternMismatch);
testing.expectEqual(true, $('#zip-good').validity.valid);
}
</script>
<script id="pattern_empty_value_does_not_mismatch">
{
// Empty value never triggers patternMismatch (per spec).
testing.expectEqual(false, $('#zip-empty').validity.patternMismatch);
testing.expectEqual(true, $('#zip-empty').validity.valid);
}
</script>
<script id="pattern_anchored_implicitly">
{
// The pattern is implicitly wrapped with ^(?:...)$ — substring "abc" inside
// "xabcy" must NOT match the whole value, so patternMismatch fires.
testing.expectEqual(true, $('#pat-anchored').validity.patternMismatch);
}
</script>
<script id="pattern_v_flag_unicode">
{
// \p{L} requires Unicode-aware matching (v or u flag).
testing.expectEqual(false, $('#pat-unicode').validity.patternMismatch);
}
</script>
<script id="pattern_invalid_regex_ignored">
{
// An unparseable pattern is ignored per spec — element stays valid.
testing.expectEqual(false, $('#pat-bad-regex').validity.patternMismatch);
}
</script>
<script id="pattern_only_text_like_types">
{
// pattern only applies to text-like types; type=number ignores the attribute.
testing.expectEqual(false, $('#pat-num').validity.patternMismatch);
}
</script>
<script id="pattern_validation_message">
{
testing.expectEqual('Please match the requested format.', $('#zip-bad').validationMessage);
testing.expectEqual('', $('#zip-good').validationMessage);
}
</script>
<script id="pattern_setter_recomputes_validity">
{
const el = $('#filled-required');
testing.expectEqual(false, el.validity.patternMismatch);
el.pattern = '[0-9]+';
testing.expectEqual(true, el.validity.patternMismatch);
el.value = '42';
testing.expectEqual(false, el.validity.patternMismatch);
}
</script>

View File

@@ -0,0 +1,50 @@
// Exercises fetch() inside a worker. Receives a command from the page,
// performs the fetch, and posts the results back.
self.onmessage = async function(e) {
const cmd = e.data;
try {
if (cmd.kind === 'basic') {
const response = await fetch('http://127.0.0.1:9582/xhr');
const text = await response.text();
postMessage({
ok: true,
status: response.status,
url: response.url,
type: response.type,
content_type: response.headers.get('Content-Type'),
length: text.length,
});
return;
}
if (cmd.kind === 'post') {
const response = await fetch('http://127.0.0.1:9582/xhr', {
method: 'POST',
body: 'hello-from-worker',
});
const text = await response.text();
postMessage({ ok: true, status: response.status, length: text.length });
return;
}
if (cmd.kind === 'blob') {
const blob = new Blob(['Hello from worker blob!'], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const response = await fetch(blobUrl);
const text = await response.text();
URL.revokeObjectURL(blobUrl);
postMessage({
ok: true,
status: response.status,
url_matches: response.url === blobUrl,
content_type: response.headers.get('Content-Type'),
text,
});
return;
}
postMessage({ ok: false, err: 'unknown command' });
} catch (err) {
postMessage({ ok: false, err: String(err), stack: err.stack });
}
};

View File

@@ -280,3 +280,53 @@
}
}
</script>
<script id=fetch_in_worker type=module>
{
const state = await testing.async();
const worker = new Worker('./fetch-worker.js');
worker.onmessage = (e) => state.resolve(e.data);
setTimeout(() => worker.postMessage({ kind: 'basic' }), 100);
await state.done((data) => {
testing.expectTrue(data.ok, 'worker fetch error: ' + data.err);
testing.expectEqual(200, data.status);
testing.expectEqual('http://127.0.0.1:9582/xhr', data.url);
testing.expectEqual('basic', data.type);
testing.expectEqual('text/html; charset=utf-8', data.content_type);
testing.expectEqual(100, data.length);
});
}
</script>
<script id=fetch_post_in_worker type=module>
{
const state = await testing.async();
const worker = new Worker('./fetch-worker.js');
worker.onmessage = (e) => state.resolve(e.data);
setTimeout(() => worker.postMessage({ kind: 'post' }), 100);
await state.done((data) => {
testing.expectTrue(data.ok, 'worker fetch error: ' + data.err);
testing.expectEqual(200, data.status);
testing.expectEqual(true, data.length > 64);
});
}
</script>
<script id=fetch_blob_url_in_worker type=module>
{
const state = await testing.async();
const worker = new Worker('./fetch-worker.js');
worker.onmessage = (e) => state.resolve(e.data);
setTimeout(() => worker.postMessage({ kind: 'blob' }), 100);
await state.done((data) => {
testing.expectTrue(data.ok, 'worker fetch error: ' + data.err);
testing.expectEqual(200, data.status);
testing.expectEqual(true, data.url_matches);
testing.expectEqual('text/plain', data.content_type);
testing.expectEqual('Hello from worker blob!', data.text);
});
}
</script>

View File

@@ -0,0 +1,74 @@
// Exercises XMLHttpRequest inside a worker. Receives a command from the page,
// performs the XHR, and posts the results back.
self.onmessage = function(e) {
const cmd = e.data;
try {
if (cmd.kind === 'basic') {
const req = new XMLHttpRequest();
const states = [];
req.onreadystatechange = () => states.push(req.readyState);
req.onload = () => {
postMessage({
ok: true,
status: req.status,
status_text: req.statusText,
response_url: req.responseURL,
response_text_length: req.responseText.length,
content_type: req.getResponseHeader('Content-Type'),
states,
});
};
req.onerror = () => postMessage({ ok: false, err: 'xhr error', status: req.status });
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
return;
}
if (cmd.kind === 'arraybuffer') {
const req = new XMLHttpRequest();
req.responseType = 'arraybuffer';
req.onload = () => {
const view = new Uint8Array(req.response);
postMessage({
ok: true,
status: req.status,
byte_length: req.response.byteLength,
first: view[0],
third: view[2],
last: view[6],
response_type: req.responseType,
});
};
req.onerror = () => postMessage({ ok: false, err: 'xhr error', status: req.status });
req.open('GET', 'http://127.0.0.1:9582/xhr/binary');
req.send();
return;
}
if (cmd.kind === 'document_unsupported') {
const req = new XMLHttpRequest();
req.responseType = 'document';
req.onload = () => {
let threw = false;
let err = null;
try {
// Reading .response in worker context with responseType=document
// must error: workers have no DOM document.
void req.response;
} catch (e) {
threw = true;
err = String(e);
}
postMessage({ ok: true, status: req.status, threw, err });
};
req.onerror = () => postMessage({ ok: false, err: 'xhr error', status: req.status });
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
return;
}
postMessage({ ok: false, err: 'unknown command' });
} catch (err) {
postMessage({ ok: false, err: String(err), stack: err.stack });
}
};

View File

@@ -76,10 +76,7 @@
await state.done(() => {
testing.expectEqual(200, req3.status);
testing.expectEqual('OK', req3.statusText);
testing.expectEqual('9000!!!', req3.response.over);
testing.expectEqual("number", typeof json.updated_at);
testing.expectEqual(1765867200000, json.updated_at);
testing.expectEqual({over: '9000!!!',updated_at:1765867200000}, json);
testing.expectEqual({over: '9000!!!', updated_at:1765867200000}, req3.response);
});
}
</script>
@@ -142,8 +139,7 @@
testing.expectEqual(200, req6.status);
testing.expectEqual('OK', req6.statusText);
testing.expectEqual(7, req6.response.byteLength);
testing.expectEqual([0, 0, 1, 2, 0, 0, 9], new Int32Array(req6.response));
testing.expectEqual('', typeof req6.response);
testing.expectEqual([0, 0, 1, 2, 0, 0, 9], new Int8Array(req6.response));
testing.expectEqual('arraybuffer', req6.responseType);
});
}
@@ -333,3 +329,60 @@
});
}
</script>
<script id=xhr_in_worker type=module>
{
const state = await testing.async();
const worker = new Worker('./xhr-worker.js');
worker.onmessage = (e) => state.resolve(e.data);
setTimeout(() => worker.postMessage({ kind: 'basic' }), 100);
await state.done((data) => {
testing.expectTrue(data.ok, 'worker xhr error: ' + data.err);
testing.expectEqual(200, data.status);
testing.expectEqual('OK', data.status_text);
testing.expectEqual('http://127.0.0.1:9582/xhr', data.response_url);
testing.expectEqual(100, data.response_text_length);
testing.expectEqual('text/html; charset=utf-8', data.content_type);
testing.expectEqual(4, data.states.length);
testing.expectEqual(1, data.states[0]);
testing.expectEqual(2, data.states[1]);
testing.expectEqual(3, data.states[2]);
testing.expectEqual(4, data.states[3]);
});
}
</script>
<script id=xhr_arraybuffer_in_worker type=module>
{
const state = await testing.async();
const worker = new Worker('./xhr-worker.js');
worker.onmessage = (e) => state.resolve(e.data);
setTimeout(() => worker.postMessage({ kind: 'arraybuffer' }), 100);
await state.done((data) => {
testing.expectTrue(data.ok, 'worker xhr error: ' + data.err);
testing.expectEqual(200, data.status);
testing.expectEqual(7, data.byte_length);
testing.expectEqual(0, data.first);
testing.expectEqual(1, data.third);
testing.expectEqual(9, data.last);
testing.expectEqual('arraybuffer', data.response_type);
});
}
</script>
<script id=xhr_document_in_worker_unsupported type=module>
{
const state = await testing.async();
const worker = new Worker('./xhr-worker.js');
worker.onmessage = (e) => state.resolve(e.data);
setTimeout(() => worker.postMessage({ kind: 'document_unsupported' }), 100);
await state.done((data) => {
testing.expectTrue(data.ok, 'worker xhr error: ' + data.err);
testing.expectEqual(200, data.status);
testing.expectEqual(true, data.threw);
});
}
</script>

View File

@@ -87,7 +87,12 @@
const res = await this.promise;
async_pending.delete(script_id);
async_capture = this.capture;
cb(res);
try {
cb(res);
} catch (err) {
console.warn(script_id, err);
failed = true;
}
async_capture = false;
}
};

View File

@@ -0,0 +1 @@
postMessage('importScripts-1');

View File

@@ -0,0 +1 @@
postMessage('importScripts-2');

View File

@@ -0,0 +1 @@
importScripts('import-script1.js', 'import-script2.js');

View File

@@ -0,0 +1,85 @@
// Exercises setTimeout / setInterval inside a WorkerGlobalScope.
// Mirrors src/browser/tests/window/timers.html.
(async function() {
try {
const results = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// setTimeout: returns a number; passes extra args through; `this` is self.
{
let timeout_this = null;
const sum = await new Promise((resolve) => {
const id = setTimeout(function (a, b) {
timeout_this = this;
resolve(a + b);
}, 1, 2, 3);
results.setTimeout_id_is_number = (typeof id === 'number');
});
results.setTimeout_args = sum;
results.setTimeout_this_is_self = (timeout_this === self);
results.setTimeout_length = setTimeout.length;
}
// setInterval fires repeatedly; clearInterval stops it.
// A second timer cleared before its first tick must never fire.
{
let count1 = 0;
const id1 = setInterval(() => { count1 += 1; }, 1);
let fired2 = false;
const id2 = setInterval(() => { fired2 = true; }, 1);
clearInterval(id2);
results.setInterval_ids_distinct = (id1 !== id2);
await sleep(10);
clearInterval(id1);
const after_clear = count1;
await sleep(5);
results.setInterval_fired_multiple = (after_clear >= 1);
results.setInterval_clear_stops = (count1 === after_clear);
results.setInterval_pre_clear_silent = !fired2;
}
// clearTimeout / clearInterval with bogus ids must be silent.
{
let threw = false;
try {
clearTimeout(-1);
clearInterval(-2);
} catch (_) { threw = true; }
results.clear_invalid_silent = !threw;
}
// Legacy: setTimeout("...", n) compiles the string into a function body.
{
self.__st_string_ran = 0;
const id = setTimeout("self.__st_string_ran = 42;", 1);
results.setTimeout_string_id_is_number = (typeof id === 'number');
await sleep(5);
results.setTimeout_string_ran = self.__st_string_ran;
}
// Legacy: setInterval("...", n) compiles the string into a function body.
{
self.__si_string_ran = 0;
const id = setInterval("self.__si_string_ran += 1;", 1);
await sleep(5);
clearInterval(id);
results.setInterval_string_ran = (self.__si_string_ran >= 1);
}
// Non-function, non-string handlers must throw.
{
let threw = false;
try { setTimeout(123, 1); } catch (_) { threw = true; }
results.setTimeout_invalid_throws = threw;
}
postMessage({ ok: true, results });
} catch (e) {
postMessage({ ok: false, err: String(e), stack: e.stack });
}
})();

View File

@@ -275,3 +275,57 @@
});
}
</script>
<script id="worker_timers" type=module>
{
const state = await testing.async();
const worker = new Worker('./timers-worker.js');
worker.onmessage = function(event) {
state.resolve(event.data);
};
await state.done((data) => {
testing.expectTrue(data.ok, 'worker timers error: ' + data.err);
const r = data.results;
testing.expectEqual(true, r.setTimeout_id_is_number);
testing.expectEqual(5, r.setTimeout_args);
testing.expectEqual(true, r.setTimeout_this_is_self);
testing.expectEqual(1, r.setTimeout_length);
testing.expectEqual(true, r.setInterval_ids_distinct);
testing.expectEqual(true, r.setInterval_fired_multiple);
testing.expectEqual(true, r.setInterval_clear_stops);
testing.expectEqual(true, r.setInterval_pre_clear_silent);
testing.expectEqual(true, r.clear_invalid_silent);
testing.expectEqual(true, r.setTimeout_string_id_is_number);
testing.expectEqual(42, r.setTimeout_string_ran);
testing.expectEqual(true, r.setInterval_string_ran);
testing.expectEqual(true, r.setTimeout_invalid_throws);
});
}
</script>
<script id="importScripts" type=module>
{
const state = await testing.async();
const worker = new Worker('importScripts-worker.js');
var received = [];
worker.onmessage = function(event) {
received.push(event);
if (received.length == 2) {
state.resolve();
}
};
await state.done(() => {
testing.expectEqual('importScripts-1', received[0].data);
testing.expectEqual('importScripts-2', received[1].data);
});
}
</script>

View File

@@ -941,7 +941,7 @@ fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const
}
fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!void {
if (session.page != null) {
if (session.hasPage()) {
registry.reset();
session.removePage();
}

View File

@@ -27,6 +27,7 @@ const EventTarget = @import("EventTarget.zig");
const ProgressEvent = @import("event/ProgressEvent.zig");
const Blob = @import("Blob.zig");
const Execution = js.Execution;
const Allocator = std.mem.Allocator;
/// https://w3c.github.io/FileAPI/#dfn-filereader
@@ -34,7 +35,7 @@ const Allocator = std.mem.Allocator;
const FileReader = @This();
_rc: lp.RC(u8) = .{},
_frame: *Frame,
_exec: *Execution,
_proto: *EventTarget,
_arena: Allocator,
@@ -63,12 +64,12 @@ const Result = union(enum) {
arraybuffer: js.ArrayBuffer,
};
pub fn init(frame: *Frame) !*FileReader {
const arena = try frame.getArena(.tiny, "FileReader");
errdefer frame.releaseArena(arena);
pub fn init(exec: *Execution) !*FileReader {
const arena = try exec.getArena(.tiny, "FileReader");
errdefer exec.releaseArena(arena);
return frame._factory.eventTargetWithAllocator(arena, FileReader{
._frame = frame,
return exec._factory.eventTargetWithAllocator(arena, FileReader{
._exec = exec,
._arena = arena,
._proto = undefined,
});
@@ -192,9 +193,9 @@ fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void {
self._error = null;
self._aborted = false;
const frame = self._frame;
const exec = self._exec;
try self.dispatch(.load_start, .{ .loaded = 0, .total = blob.getSize() }, frame);
try self.dispatch(.load_start, .{ .loaded = 0, .total = blob.getSize() }, exec);
if (self._aborted) {
return;
}
@@ -202,7 +203,7 @@ fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void {
// Perform the read (synchronous since data is in memory)
const data = blob._slice;
const size = data.len;
try self.dispatch(.progress, .{ .loaded = size, .total = size }, frame);
try self.dispatch(.progress, .{ .loaded = size, .total = size }, exec);
if (self._aborted) {
return;
}
@@ -222,8 +223,8 @@ fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void {
self._ready_state = .done;
try self.dispatch(.load, .{ .loaded = size, .total = size }, frame);
try self.dispatch(.load_end, .{ .loaded = size, .total = size }, frame);
try self.dispatch(.load, .{ .loaded = size, .total = size }, exec);
try self.dispatch(.load_end, .{ .loaded = size, .total = size }, exec);
}
pub fn abort(self: *FileReader) !void {
@@ -235,14 +236,12 @@ pub fn abort(self: *FileReader) !void {
self._ready_state = .done;
self._result = null;
const frame = self._frame;
try self.dispatch(.abort, null, frame);
try self.dispatch(.load_end, null, frame);
const exec = self._exec;
try self.dispatch(.abort, null, exec);
try self.dispatch(.load_end, null, exec);
}
fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Progress, frame: *Frame) !void {
fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Progress, exec: *Execution) !void {
const field, const typ = comptime blk: {
break :blk switch (event_type) {
.abort => .{ "_on_abort", "abort" },
@@ -258,10 +257,10 @@ fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Pr
const event = (try ProgressEvent.initTrusted(
comptime .wrap(typ),
.{ .total = progress.total, .loaded = progress.loaded },
frame,
exec.context.page,
)).asEvent();
return frame._event_manager.dispatchDirect(
return exec.dispatch(
self.asEventTarget(),
event,
@field(self, field),

View File

@@ -0,0 +1,205 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
// Shared bookkeeping for setTimeout / setInterval (and Window-only
// setImmediate / requestAnimationFrame / requestIdleCallback). Both Window
// and WorkerGlobalScope embed a Timers and forward their JS-bridged
// methods through `schedule` / `clear`.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
const Timers = @This();
_timer_id: u30 = 0,
_callbacks: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{},
pub const Mode = enum {
idle,
normal,
animation_frame,
};
pub const ScheduleOpts = struct {
repeat: bool,
params: []js.Value.Temp,
name: []const u8,
low_priority: bool = false,
mode: Mode = .normal,
};
pub fn schedule(
self: *Timers,
exec: *js.Execution,
cb: js.Function.Temp,
delay_ms: u32,
opts: ScheduleOpts,
) !u32 {
if (self._callbacks.count() > 512) {
// these are active
return error.TooManyTimeout;
}
const arena = try exec.getArena(.tiny, "Timers.schedule");
errdefer exec.releaseArena(arena);
const timer_id = self._timer_id +% 1;
self._timer_id = timer_id;
var persisted_params: []js.Value.Temp = &.{};
if (opts.params.len > 0) {
persisted_params = try arena.dupe(js.Value.Temp, opts.params);
}
const gop = try self._callbacks.getOrPut(exec.arena, timer_id);
if (gop.found_existing) {
// 2^31 would have to wrap for this to happen.
return error.TooManyTimeout;
}
errdefer _ = self._callbacks.remove(timer_id);
const callback = try arena.create(ScheduleCallback);
callback.* = .{
.cb = cb,
.exec = exec,
.timers = self,
.arena = arena,
.mode = opts.mode,
.name = opts.name,
.timer_id = timer_id,
.params = persisted_params,
.repeat_ms = if (opts.repeat) if (delay_ms == 0) 1 else delay_ms else null,
};
gop.value_ptr.* = callback;
try exec.context.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{
.name = opts.name,
.low_priority = opts.low_priority,
.finalizer = ScheduleCallback.cancelled,
});
return timer_id;
}
pub fn clear(self: *Timers, id: u32) void {
var sc = self._callbacks.fetchRemove(id) orelse return;
sc.value.removed = true;
}
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timerhandler
// TimerHandler = Function or DOMString. When a string is passed, it is
// compiled into an anonymous function body, matching how legacy browsers
// (and all current UAs) interpret `setTimeout("foo()", 100)`.
pub const LegacyHandler = union(enum) {
function: js.Function.Temp,
string: js.String,
pub fn resolve(handler: LegacyHandler, exec: *js.Execution) !js.Function.Temp {
switch (handler) {
.function => |fun| return fun,
.string => |str| {
const fun = try exec.context.local.?.compileFunction(str, &.{}, &.{});
return fun.temp();
},
}
}
};
const ScheduleCallback = struct {
// for debugging
name: []const u8,
// Timers._callbacks key
timer_id: u31,
// delay, in ms, to repeat. When null, removed after first invocation.
repeat_ms: ?u32,
cb: js.Function.Temp,
mode: Mode,
exec: *js.Execution,
timers: *Timers,
arena: Allocator,
removed: bool = false,
params: []const js.Value.Temp,
fn cancelled(ptr: *anyopaque) void {
var self: *ScheduleCallback = @ptrCast(@alignCast(ptr));
self.deinit();
}
fn deinit(self: *ScheduleCallback) void {
self.cb.release();
for (self.params) |param| {
param.release();
}
self.exec.releaseArena(self.arena);
}
fn run(ptr: *anyopaque) !?u32 {
const self: *ScheduleCallback = @ptrCast(@alignCast(ptr));
if (self.removed) {
self.deinit();
return null;
}
var ls: js.Local.Scope = undefined;
self.exec.context.localScope(&ls);
defer ls.deinit();
switch (self.mode) {
.idle => {
const IdleDeadline = @import("IdleDeadline.zig");
ls.toLocal(self.cb).call(void, .{IdleDeadline{}}) catch |err| {
log.warn(.js, "idleCallback", .{ .name = self.name, .err = err });
};
},
.animation_frame => {
// requestAnimationFrame is window-only; if a worker ever
// schedules with this mode it's a programming error.
const window = switch (self.exec.context.global) {
.frame => |frame| frame.window,
.worker => unreachable,
};
ls.toLocal(self.cb).call(void, .{window._performance.now()}) catch |err| {
log.warn(.js, "RAF", .{ .name = self.name, .err = err });
};
},
.normal => {
ls.toLocal(self.cb).call(void, self.params) catch |err| {
log.warn(.js, "timer", .{ .name = self.name, .err = err });
};
},
}
ls.local.runMicrotasks();
if (self.repeat_ms) |ms| {
return ms;
}
defer self.deinit();
_ = self.timers._callbacks.remove(self.timer_id);
return null;
}
};

View File

@@ -44,6 +44,7 @@ const Element = @import("Element.zig");
const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
const CustomElementRegistry = @import("CustomElementRegistry.zig");
const Selection = @import("Selection.zig");
const Timers = @import("Timers.zig");
const Notification = @import("../../Notification.zig");
const log = lp.log;
@@ -77,8 +78,7 @@ _on_rejection_handled: ?js.Function.Global = null,
_on_unhandled_rejection: ?js.Function.Global = null,
_current_event: ?*Event = null,
_location: *Location,
_timer_id: u30 = 0,
_timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{},
_timers: Timers = .{},
_custom_elements: CustomElementRegistry = .{},
_scroll_pos: struct {
x: u32,
@@ -282,67 +282,43 @@ pub fn setOnUnhandledRejection(self: *Window, setter: ?FunctionSetter) void {
self._on_unhandled_rejection = getFunctionFromSetter(setter);
}
pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.InitOpts, frame: *Frame) !js.Promise {
return Fetch.init(input, options, frame);
pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.InitOpts, exec: *const js.Execution) !js.Promise {
return Fetch.init(input, options, exec);
}
const LegacyHandler = union(enum) {
function: js.Function.Temp,
string: js.String,
};
pub fn setTimeout(self: *Window, handler: LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, frame: *Frame) !u32 {
const cb = try resolveTimerHandler(handler, frame);
return self.scheduleCallback(cb, delay_ms orelse 0, .{
pub fn setTimeout(self: *Window, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, exec: *js.Execution) !u32 {
const cb = try handler.resolve(exec);
return self._timers.schedule(exec, cb, delay_ms orelse 0, .{
.repeat = false,
.params = params,
.low_priority = false,
.name = "window.setTimeout",
}, frame);
});
}
pub fn setInterval(self: *Window, handler: LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, frame: *Frame) !u32 {
const cb = try resolveTimerHandler(handler, frame);
return self.scheduleCallback(cb, delay_ms orelse 0, .{
pub fn setInterval(self: *Window, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, exec: *js.Execution) !u32 {
const cb = try handler.resolve(exec);
return self._timers.schedule(exec, cb, delay_ms orelse 0, .{
.repeat = true,
.params = params,
.low_priority = false,
.name = "window.setInterval",
}, frame);
});
}
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timerhandler
// TimerHandler = Function or DOMString. When a string is passed, it is
// compiled into an anonymous function body, matching how legacy browsers
// (and all current UAs) interpret `setTimeout("foo()", 100)`.
fn resolveTimerHandler(handler: LegacyHandler, frame: *Frame) !js.Function.Temp {
switch (handler) {
.function => |fun| return fun,
.string => |str| {
const fun = try frame.js.local.?.compileFunction(str, &.{}, &.{});
return fun.temp();
},
}
}
pub fn setImmediate(self: *Window, cb: js.Function.Temp, params: []js.Value.Temp, frame: *Frame) !u32 {
return self.scheduleCallback(cb, 0, .{
pub fn setImmediate(self: *Window, cb: js.Function.Temp, params: []js.Value.Temp, exec: *js.Execution) !u32 {
return self._timers.schedule(exec, cb, 0, .{
.repeat = false,
.params = params,
.low_priority = false,
.name = "window.setImmediate",
}, frame);
});
}
pub fn requestAnimationFrame(self: *Window, cb: js.Function.Temp, frame: *Frame) !u32 {
return self.scheduleCallback(cb, 5, .{
pub fn requestAnimationFrame(self: *Window, cb: js.Function.Temp, exec: *js.Execution) !u32 {
return self._timers.schedule(exec, cb, 5, .{
.repeat = false,
.params = &.{},
.low_priority = false,
.mode = .animation_frame,
.name = "window.requestAnimationFrame",
}, frame);
});
}
pub fn queueMicrotask(_: *Window, cb: js.Function, frame: *Frame) void {
@@ -350,42 +326,37 @@ pub fn queueMicrotask(_: *Window, cb: js.Function, frame: *Frame) void {
}
pub fn clearTimeout(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
pub fn clearInterval(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
pub fn clearImmediate(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
pub fn cancelAnimationFrame(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
const RequestIdleCallbackOpts = struct {
timeout: ?u32 = null,
};
pub fn requestIdleCallback(self: *Window, cb: js.Function.Temp, opts_: ?RequestIdleCallbackOpts, frame: *Frame) !u32 {
pub fn requestIdleCallback(self: *Window, cb: js.Function.Temp, opts_: ?RequestIdleCallbackOpts, exec: *js.Execution) !u32 {
const opts = opts_ orelse RequestIdleCallbackOpts{};
return self.scheduleCallback(cb, opts.timeout orelse 50, .{
return self._timers.schedule(exec, cb, opts.timeout orelse 50, .{
.mode = .idle,
.repeat = false,
.params = &.{},
.low_priority = true,
.name = "window.requestIdleCallback",
}, frame);
});
}
pub fn cancelIdleCallback(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void {
@@ -568,6 +539,8 @@ pub fn close(self: *Window) void {
}
}
frame.js.scheduler.reset();
// We can't tear the Frame down here — close() is invoked from JS still
// running on top of this Frame's V8 context, often deep inside a script
// eval whose parser is still holding the Frame. Destroying the context
@@ -799,140 +772,6 @@ pub const Access = union(enum) {
}
};
const ScheduleOpts = struct {
repeat: bool,
params: []js.Value.Temp,
name: []const u8,
low_priority: bool = false,
animation_frame: bool = false,
mode: ScheduleCallback.Mode = .normal,
};
fn scheduleCallback(self: *Window, cb: js.Function.Temp, delay_ms: u32, opts: ScheduleOpts, frame: *Frame) !u32 {
if (self._timers.count() > 512) {
// these are active
return error.TooManyTimeout;
}
const arena = try frame.getArena(.tiny, "Window.schedule");
errdefer frame.releaseArena(arena);
const timer_id = self._timer_id +% 1;
self._timer_id = timer_id;
const params = opts.params;
var persisted_params: []js.Value.Temp = &.{};
if (params.len > 0) {
persisted_params = try arena.dupe(js.Value.Temp, params);
}
const gop = try self._timers.getOrPut(frame.arena, timer_id);
if (gop.found_existing) {
// 2^31 would have to wrap for this to happen.
return error.TooManyTimeout;
}
errdefer _ = self._timers.remove(timer_id);
const callback = try arena.create(ScheduleCallback);
callback.* = .{
.cb = cb,
.frame = frame,
.arena = arena,
.mode = opts.mode,
.name = opts.name,
.timer_id = timer_id,
.params = persisted_params,
.repeat_ms = if (opts.repeat) if (delay_ms == 0) 1 else delay_ms else null,
};
gop.value_ptr.* = callback;
try frame.js.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{
.name = opts.name,
.low_priority = opts.low_priority,
.finalizer = ScheduleCallback.cancelled,
});
return timer_id;
}
const ScheduleCallback = struct {
// for debugging
name: []const u8,
// window._timers key
timer_id: u31,
// delay, in ms, to repeat. When null, will be removed after the first time
repeat_ms: ?u32,
cb: js.Function.Temp,
mode: Mode,
frame: *Frame,
arena: Allocator,
removed: bool = false,
params: []const js.Value.Temp,
const Mode = enum {
idle,
normal,
animation_frame,
};
fn cancelled(ctx: *anyopaque) void {
var self: *ScheduleCallback = @ptrCast(@alignCast(ctx));
self.deinit();
}
fn deinit(self: *ScheduleCallback) void {
self.cb.release();
for (self.params) |param| {
param.release();
}
self.frame.releaseArena(self.arena);
}
fn run(ctx: *anyopaque) !?u32 {
const self: *ScheduleCallback = @ptrCast(@alignCast(ctx));
const frame = self.frame;
const window = frame.window;
if (self.removed) {
self.deinit();
return null;
}
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
switch (self.mode) {
.idle => {
const IdleDeadline = @import("IdleDeadline.zig");
ls.toLocal(self.cb).call(void, .{IdleDeadline{}}) catch |err| {
log.warn(.js, "window.idleCallback", .{ .name = self.name, .err = err });
};
},
.animation_frame => {
ls.toLocal(self.cb).call(void, .{window._performance.now()}) catch |err| {
log.warn(.js, "window.RAF", .{ .name = self.name, .err = err });
};
},
.normal => {
ls.toLocal(self.cb).call(void, self.params) catch |err| {
log.warn(.js, "window.timer", .{ .name = self.name, .err = err });
};
},
}
ls.local.runMicrotasks();
if (self.repeat_ms) |ms| {
return ms;
}
defer self.deinit();
_ = window._timers.remove(self.timer_id);
return null;
}
};
const PostMessageCallback = struct {
frame: *Frame,
source: *Window,

View File

@@ -68,7 +68,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker {
const arena = try session.getArena(.large, "Worker");
errdefer session.releaseArena(arena);
const resolved_url = try URL.resolve(arena, exec.url.*, url, .{});
const resolved_url = try URL.resolve(arena, exec.url.*, url, .{ .encoding = frame.charset });
const self = try frame._page.factory.eventTargetWithAllocator(arena, Worker{
._arena = arena,
._proto = undefined,
@@ -92,7 +92,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker {
return self;
}
const http_client = session.browser.http_client;
const http_client = &session.browser.http_client;
http_client.request(.{
.ctx = self,
.params = .{
@@ -121,6 +121,8 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker {
// Called from Frame.deinit when the frame is destroyed, so we don't need to
// remove from the frame's worker list.
pub fn deinit(self: *Worker) void {
// No pending frame for workers, so we can abort all frames.
self._frame._session.browser.http_client.abortFrame(self._frame_id, .{ .scope = .full });
if (self._http_response) |res| {
res.abort(error.Abort);
self._http_response = null;

View File

@@ -24,19 +24,24 @@ const std = @import("std");
const lp = @import("lightpanda");
const JS = @import("../js/js.zig");
const URL = @import("../URL.zig");
const Page = @import("../Page.zig");
const Factory = @import("../Factory.zig");
const Session = @import("../Session.zig");
const HttpClient = @import("../HttpClient.zig");
const EventManagerBase = @import("../EventManagerBase.zig");
const ScriptManagerBase = @import("../ScriptManagerBase.zig");
const Blob = @import("Blob.zig");
const Event = @import("Event.zig");
const Worker = @import("Worker.zig");
const Crypto = @import("Crypto.zig");
const Console = @import("Console.zig");
const Timers = @import("Timers.zig");
const EventTarget = @import("EventTarget.zig");
const MessageEvent = @import("event/MessageEvent.zig");
const ErrorEvent = @import("event/ErrorEvent.zig");
const Fetch = @import("net/Fetch.zig");
const builtin = @import("builtin");
const IS_DEBUG = builtin.mode == .Debug;
@@ -49,8 +54,8 @@ const WorkerGlobalScope = @This();
// Meant to follow the same field naming as Page so that an anytype of generic
// can access these the same for a Page of a WGS.
// These fields represent the "Page"-like component of the WGS
_session: *Session,
_page: *Page,
_session: *Session,
_factory: *Factory,
_identity: JS.Identity = .{},
arena: Allocator,
@@ -69,6 +74,12 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{},
// Reference back to the Worker object (for postMessage to frame)
_worker: *Worker,
// HTTP attribution. Mirrors Frame's fields so that generic code over
// (Frame|WorkerGlobalScope) can read them uniformly. Populated from the
// owning Worker at init.
_frame_id: u32,
_loader_id: u32,
// Event management for non-DOM targets in worker context
_event_manager: EventManagerBase,
@@ -87,6 +98,8 @@ _on_unhandled_rejection: ?JS.Function.Global = null,
_on_message: ?JS.Function.Global = null,
_on_messageerror: ?JS.Function.Global = null,
_timers: Timers = .{},
pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope {
const arena = worker._arena;
const parent = worker._frame;
@@ -108,6 +121,8 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope {
._proto = undefined,
._factory = factory,
._worker = worker,
._frame_id = worker._frame_id,
._loader_id = worker._loader_id,
._event_manager = .init(arena),
._script_manager = undefined,
});
@@ -115,7 +130,7 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope {
self._script_manager = ScriptManagerBase.init(
arena,
session.browser.http_client,
&session.browser.http_client,
.{ .worker = self },
);
@@ -149,8 +164,6 @@ pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget {
return self._proto;
}
const Event = @import("Event.zig");
// Dispatch an event to listeners on the given target within this worker context.
pub fn dispatch(
self: *WorkerGlobalScope,
@@ -170,6 +183,29 @@ pub fn dispatch(
);
}
pub fn hasDirectListeners(self: *WorkerGlobalScope, target: *EventTarget, typ: []const u8, handler: anytype) bool {
return self._event_manager.hasDirectListeners(target, typ, handler);
}
// Workers don't have their own Referer; per spec, dedicated worker requests
// use the parent document's URL. Delegate to the owning frame.
pub fn headersForRequest(self: *WorkerGlobalScope, headers: *HttpClient.Headers) !void {
return self._worker._frame.headersForRequest(headers);
}
pub fn isSameOrigin(self: *const WorkerGlobalScope, url: [:0]const u8) bool {
const current_origin = self.origin orelse return false;
if (!std.mem.startsWith(u8, url, current_origin)) {
return false;
}
return std.mem.eql(u8, URL.getHost(url), URL.getHost(current_origin));
}
pub fn lookupBlobUrl(self: *WorkerGlobalScope, url: []const u8) ?*Blob {
return self._blob_urls.get(url);
}
pub fn getSelf(self: *WorkerGlobalScope) *WorkerGlobalScope {
return self;
}
@@ -309,6 +345,64 @@ pub fn close(self: *WorkerGlobalScope) void {
self._closed = true;
}
pub fn importScripts(self: *WorkerGlobalScope, urls: []const [:0]const u8) !void {
const session = self._session;
const arena = try session.getArena(.large, "importScript");
defer session.releaseArena(arena);
for (urls) |url| {
defer session.arena_pool.resetRetain(arena);
try self.importScript(arena, url);
}
}
fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) !void {
const session = self._session;
const resolved_url = try URL.resolve(arena, self.url, url, .{});
const http_client = &session.browser.http_client;
var headers = try http_client.newHeaders();
try self.headersForRequest(&headers);
const response = http_client.syncRequest(arena, .{
.url = resolved_url,
.method = .GET,
.frame_id = self._frame_id,
.loader_id = self._loader_id,
.headers = headers,
.cookie_jar = &session.cookie_jar,
.cookie_origin = self.url,
.resource_type = .script,
.notification = session.notification,
}) catch |err| {
log.warn(.http, "importScript", .{ .url = resolved_url, .err = err });
return error.NetworkError;
};
if (response.status != 200) {
log.warn(.http, "importScript", .{ .url = resolved_url, .status = response.status });
return error.NetworkError;
}
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
var try_catch: JS.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
_ = ls.local.eval(response.body.items, url) catch |err| {
const caught = try_catch.caughtOrError(arena, err);
log.err(.browser, "importScript", .{ .url = resolved_url, .caught = caught });
return;
};
ls.local.runMacrotasks();
}
pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void {
const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{
.@"error" = try err.temp(),
@@ -359,10 +453,39 @@ pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void {
}
}
// TODO: importScripts - needs script loading infrastructure
// TODO: location - needs WorkerLocation
// TODO: navigator - needs WorkerNavigator
// TODO: Timer functions - need scheduler integration
pub fn fetch(_: *const WorkerGlobalScope, input: Fetch.Input, options: ?Fetch.InitOpts, exec: *const JS.Execution) !JS.Promise {
return Fetch.init(input, options, exec);
}
pub fn queueMicrotask(self: *WorkerGlobalScope, cb: JS.Function) void {
self.js.queueMicrotaskFunc(cb);
}
pub fn setTimeout(self: *WorkerGlobalScope, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []JS.Value.Temp, exec: *JS.Execution) !u32 {
const cb = try handler.resolve(exec);
return self._timers.schedule(exec, cb, delay_ms orelse 0, .{
.repeat = false,
.params = params,
.name = "worker.setTimeout",
});
}
pub fn clearTimeout(self: *WorkerGlobalScope, id: u32) void {
self._timers.clear(id);
}
pub fn setInterval(self: *WorkerGlobalScope, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []JS.Value.Temp, exec: *JS.Execution) !u32 {
const cb = try handler.resolve(exec);
return self._timers.schedule(exec, cb, delay_ms orelse 0, .{
.repeat = true,
.params = params,
.name = "worker.setInterval",
});
}
pub fn clearInterval(self: *WorkerGlobalScope, id: u32) void {
self._timers.clear(id);
}
const FunctionSetter = union(enum) {
func: JS.Function.Global,
@@ -454,6 +577,13 @@ pub const JsApi = struct {
pub const postMessage = bridge.function(WorkerGlobalScope.postMessage, .{});
pub const reportError = bridge.function(WorkerGlobalScope.reportError, .{});
pub const close = bridge.function(WorkerGlobalScope.close, .{});
pub const fetch = bridge.function(WorkerGlobalScope.fetch, .{});
pub const importScripts = bridge.function(WorkerGlobalScope.importScripts, .{ .dom_exception = true });
pub const queueMicrotask = bridge.function(WorkerGlobalScope.queueMicrotask, .{});
pub const setTimeout = bridge.function(WorkerGlobalScope.setTimeout, .{});
pub const clearTimeout = bridge.function(WorkerGlobalScope.clearTimeout, .{});
pub const setInterval = bridge.function(WorkerGlobalScope.setInterval, .{});
pub const clearInterval = bridge.function(WorkerGlobalScope.clearInterval, .{});
pub const onmessage = bridge.accessor(WorkerGlobalScope.getOnMessage, WorkerGlobalScope.setOnMessage, .{});
pub const onmessageerror = bridge.accessor(WorkerGlobalScope.getOnMessageError, WorkerGlobalScope.setOnMessageError, .{});

View File

@@ -233,7 +233,7 @@ pub fn getValidationMessage(self: *const Input, frame: *Frame) []const u8 {
.url => "Please enter a URL.",
else => "Please enter a valid value.",
};
if (self.suffersPatternMismatch()) return "Please match the requested format.";
if (self.suffersPatternMismatch(frame)) return "Please match the requested format.";
if (self.suffersTooLong()) return "Please shorten this text.";
if (self.suffersTooShort()) return "Please lengthen this text.";
if (self.suffersRangeUnderflow()) return "Value is too small.";
@@ -295,12 +295,35 @@ pub fn suffersTypeMismatch(self: *const Input) bool {
};
}
pub fn suffersPatternMismatch(self: *const Input) bool {
_ = self;
// Pattern matching requires evaluating a JS RegExp anchored with ^(?: ... )$.
// Not yet implemented from Zig; returning false leaves well-formed inputs valid.
// TODO: route through the V8 RegExp constructor on the owner Frame.
return false;
pub fn suffersPatternMismatch(self: *const Input, frame: *Frame) bool {
if (!self.getWillValidate()) return false;
// Per HTML §4.10.5.3.5, pattern only applies to text-like input types.
switch (self._input_type) {
.text, .search, .url, .tel, .email, .password => {},
else => return false,
}
const value = self._value orelse return false;
if (value.len == 0) return false;
const pattern = self.asConstElement().getAttributeSafe(comptime .wrap("pattern")) orelse return false;
if (pattern.len == 0) return false;
// Per HTML spec, anchor the pattern with ^(?:...)$ and compile under the
// "v" (Unicode sets) flag. An invalid pattern is ignored — V8 throws and
// we treat that as "no mismatch". TryCatch absorbs the exception so it
// doesn't linger in the isolate.
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
const wrapped = std.fmt.allocPrint(frame.call_arena, "^(?:{s})$", .{pattern}) catch return false;
const re = js.RegExp.init(&ls.local, wrapped, js.RegExp.Flag.unicode_sets) catch return false;
const matched = re.match(value) catch return false;
return !matched;
}
pub fn suffersTooLong(self: *const Input) bool {
@@ -597,6 +620,14 @@ pub fn setPlaceholder(self: *Input, placeholder: []const u8, frame: *Frame) !voi
try self.asElement().setAttributeSafe(comptime .wrap("placeholder"), .wrap(placeholder), frame);
}
pub fn getPattern(self: *const Input) []const u8 {
return self.asConstElement().getAttributeSafe(comptime .wrap("pattern")) orelse "";
}
pub fn setPattern(self: *Input, pattern: []const u8, frame: *Frame) !void {
try self.asElement().setAttributeSafe(comptime .wrap("pattern"), .wrap(pattern), frame);
}
pub fn getMin(self: *const Input) []const u8 {
return self.asConstElement().getAttributeSafe(comptime .wrap("min")) orelse "";
}
@@ -1237,6 +1268,7 @@ pub const JsApi = struct {
pub const labels = bridge.accessor(Input.getLabels, null, .{});
pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{});
pub const placeholder = bridge.accessor(Input.getPlaceholder, Input.setPlaceholder, .{});
pub const pattern = bridge.accessor(Input.getPattern, Input.setPattern, .{});
pub const min = bridge.accessor(Input.getMin, Input.setMin, .{});
pub const max = bridge.accessor(Input.getMax, Input.setMax, .{});
pub const step = bridge.accessor(Input.getStep, Input.setStep, .{});

View File

@@ -46,8 +46,8 @@ pub fn getTypeMismatch(self: *const ValidityState) bool {
return false;
}
pub fn getPatternMismatch(self: *const ValidityState) bool {
if (self._owner.is(Input)) |input| return input.suffersPatternMismatch();
pub fn getPatternMismatch(self: *const ValidityState, frame: *Frame) bool {
if (self._owner.is(Input)) |input| return input.suffersPatternMismatch(frame);
return false;
}
@@ -100,7 +100,7 @@ pub fn getCustomError(self: *const ValidityState) bool {
pub fn getValid(self: *const ValidityState, frame: *Frame) bool {
return !self.getValueMissing(frame) and
!self.getTypeMismatch() and
!self.getPatternMismatch() and
!self.getPatternMismatch(frame) and
!self.getTooLong() and
!self.getTooShort() and
!self.getRangeUnderflow() and

View File

@@ -19,7 +19,7 @@
const std = @import("std");
const lp = @import("lightpanda");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const Event = @import("../Event.zig");
const String = lp.String;
@@ -39,23 +39,23 @@ const ProgressEventOptions = struct {
const Options = Event.inheritOptions(ProgressEvent, ProgressEventOptions);
pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*ProgressEvent {
const arena = try frame.getArena(.tiny, "ProgressEvent");
errdefer frame.releaseArena(arena);
pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*ProgressEvent {
const arena = try page.getArena(.tiny, "ProgressEvent");
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, _opts, false, frame);
return initWithTrusted(arena, type_string, _opts, false, page);
}
pub fn initTrusted(typ: String, _opts: ?Options, frame: *Frame) !*ProgressEvent {
const arena = try frame.getArena(.tiny, "ProgressEvent.trusted");
errdefer frame.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, frame);
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*ProgressEvent {
const arena = try page.getArena(.tiny, "ProgressEvent.trusted");
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, page);
}
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, frame: *Frame) !*ProgressEvent {
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*ProgressEvent {
const opts = _opts orelse Options{};
const event = try frame._factory.event(
const event = try page.factory.event(
arena,
typ,
ProgressEvent{

View File

@@ -21,7 +21,7 @@ const lp = @import("lightpanda");
const HttpClient = @import("../../HttpClient.zig");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const URL = @import("../../URL.zig");
const Blob = @import("../Blob.zig");
@@ -31,11 +31,12 @@ const AbortSignal = @import("../AbortSignal.zig");
const DOMException = @import("../DOMException.zig");
const log = lp.log;
const Execution = js.Execution;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Fetch = @This();
_frame: *Frame,
_exec: *const Execution,
_url: []const u8,
_buf: std.ArrayList(u8),
_response: *Response,
@@ -46,9 +47,9 @@ _signal: ?*AbortSignal,
pub const Input = Request.Input;
pub const InitOpts = Request.InitOpts;
pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise {
const request = try Request.init(input, options, &frame.js.execution);
const resolver = frame.js.local.?.createPromiseResolver();
pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promise {
const request = try Request.init(input, options, exec);
const resolver = exec.context.local.?.createPromiseResolver();
if (request._signal) |signal| {
if (signal._aborted) {
@@ -58,15 +59,15 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise {
}
if (std.mem.startsWith(u8, request._url, "blob:")) {
return handleBlobUrl(request._url, resolver, frame);
return handleBlobUrl(request._url, resolver, exec);
}
const response = try Response.init(null, .{ .status = 0 }, &frame.js.execution);
errdefer response.deinit(frame._page);
const response = try Response.init(null, .{ .status = 0 }, exec);
errdefer response.deinit(exec.context.page);
const fetch = try response._arena.create(Fetch);
fetch.* = .{
._frame = frame,
._exec = exec,
._buf = .empty,
._url = try response._arena.dupe(u8, request._url),
._resolver = try resolver.persist(),
@@ -75,12 +76,13 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise {
._signal = request._signal,
};
const http_client = frame._session.browser.http_client;
const session = exec.context.page.session;
const http_client = &session.browser.http_client;
var headers = try http_client.newHeaders();
if (request._headers) |h| {
try h.populateHttpHeader(frame.call_arena, &headers);
try h.populateHttpHeader(exec.call_arena, &headers);
}
try frame.headersForRequest(&headers);
try exec.headersForRequest(&headers);
if (comptime IS_DEBUG) {
log.debug(.http, "fetch", .{ .url = request._url });
@@ -88,8 +90,8 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise {
const cookie_jar = switch (request._credentials) {
.omit => null,
.include => &frame._session.cookie_jar,
.@"same-origin" => if (frame.isSameOrigin(request._url)) &frame._session.cookie_jar else null,
.include => &session.cookie_jar,
.@"same-origin" => if (exec.isSameOrigin(request._url)) &session.cookie_jar else null,
};
try http_client.request(.{
@@ -97,14 +99,14 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise {
.params = .{
.url = request._url,
.method = request._method,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.frame_id = exec.frameId(),
.loader_id = exec.loaderId(),
.body = request._body,
.headers = headers,
.resource_type = .fetch,
.cookie_jar = cookie_jar,
.cookie_origin = frame.url,
.notification = frame._session.notification,
.cookie_origin = exec.url.*,
.notification = session.notification,
},
.start_callback = httpStartCallback,
.header_callback = httpHeaderDoneCallback,
@@ -116,22 +118,22 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise {
return resolver.promise();
}
fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, frame: *Frame) !js.Promise {
const blob: *Blob = frame.lookupBlobUrl(url) orelse {
fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, exec: *const Execution) !js.Promise {
const blob: *Blob = exec.lookupBlobUrl(url) orelse {
resolver.rejectError("fetch blob error", .{ .type_error = "BlobNotFound" });
return resolver.promise();
};
const response = try Response.init(null, .{ .status = 200 }, &frame.js.execution);
const response = try Response.init(null, .{ .status = 200 }, exec);
response._body = .{ .bytes = try response._arena.dupe(u8, blob._slice) };
response._url = try response._arena.dupeZ(u8, url);
response._type = .basic;
if (blob._mime.len > 0) {
try response._headers.append("Content-Type", blob._mime, &frame.js.execution);
try response._headers.append("Content-Type", blob._mime, exec);
}
const js_val = try frame.js.local.?.zigValueToJs(response, .{});
const js_val = try exec.context.local.?.zigValueToJs(response, .{});
resolver.resolve("fetch blob done", js_val);
return resolver.promise();
}
@@ -174,10 +176,11 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool {
res._is_redirected = response.redirectCount().? > 0;
// Determine response type based on origin comparison
const frame_origin = URL.getOrigin(arena, self._frame.url) catch null;
const exec = self._exec;
const requesting_origin = URL.getOrigin(arena, exec.url.*) catch null;
const response_origin = URL.getOrigin(arena, res._url) catch null;
if (frame_origin) |fo| {
if (requesting_origin) |fo| {
if (response_origin) |ro| {
if (std.mem.eql(u8, fo, ro)) {
res._type = .basic; // Same-origin
@@ -193,7 +196,7 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool {
var it = response.headerIterator();
while (it.next()) |hdr| {
try res._headers.append(hdr.name, hdr.value, &self._frame.js.execution);
try res._headers.append(hdr.name, hdr.value, exec);
}
return true;
@@ -226,7 +229,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
});
var ls: js.Local.Scope = undefined;
self._frame.js.localScope(&ls);
self._exec.context.localScope(&ls);
defer ls.deinit();
const js_val = try ls.local.zigValueToJs(self._response, .{});
@@ -250,11 +253,11 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
// clear this. (defer since `self is in the response's arena).
defer if (self._owns_response) {
response.deinit(self._frame._page);
response.deinit(self._exec.context.page);
};
var ls: js.Local.Scope = undefined;
self._frame.js.localScope(&ls);
self._exec.context.localScope(&ls);
defer ls.deinit();
// fetch() must reject with a TypeError on network errors per spec
@@ -271,7 +274,7 @@ fn httpShutdownCallback(ctx: *anyopaque) void {
if (self._owns_response) {
var response = self._response;
response._http_response = null;
response.deinit(self._frame._page);
response.deinit(self._exec.context.page);
// Do not access `self` after this point: the Fetch struct was
// allocated from response._arena which has been released.
}

View File

@@ -115,7 +115,7 @@ pub fn init(url: []const u8, protocols: [][]const u8, frame: *Frame) !*WebSocket
const resolved_url = try URL.resolve(arena, frame.base(), url, .{ .always_dupe = true, .encoding = frame.charset });
const http_client = frame._session.browser.http_client;
const http_client = &frame._session.browser.http_client;
const conn = http_client.network.newConnection() orelse {
return error.NoFreeConnection;
};

View File

@@ -26,7 +26,6 @@ const http = @import("../../../network/http.zig");
const URL = @import("../../URL.zig");
const Mime = @import("../../Mime.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Node = @import("../Node.zig");
const Event = @import("../Event.zig");
@@ -35,12 +34,13 @@ const EventTarget = @import("../EventTarget.zig");
const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig");
const log = lp.log;
const Execution = js.Execution;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const XMLHttpRequest = @This();
_rc: lp.RC(u8) = .{},
_frame: *Frame,
_exec: *const Execution,
_proto: *XMLHttpRequestEventTarget,
_arena: Allocator,
_http_response: ?HttpClient.Response = null,
@@ -88,14 +88,14 @@ const ResponseType = enum {
// TODO: other types to support
};
pub fn init(frame: *Frame) !*XMLHttpRequest {
const arena = try frame.getArena(.large, "XMLHttpRequest");
errdefer frame.releaseArena(arena);
const self = try frame._factory.xhrEventTarget(arena, XMLHttpRequest{
._frame = frame,
pub fn init(exec: *const Execution) !*XMLHttpRequest {
const arena = try exec.getArena(.large, "XMLHttpRequest");
errdefer exec.releaseArena(arena);
const self = try exec._factory.xhrEventTarget(arena, XMLHttpRequest{
._exec = exec,
._arena = arena,
._proto = undefined,
._request_headers = try Headers.init(null, &frame.js.execution),
._request_headers = try Headers.init(null, exec),
});
return self;
}
@@ -142,7 +142,7 @@ fn releaseSelfRef(self: *XMLHttpRequest) void {
if (self._active_request == false) {
return;
}
self.releaseRef(self._frame._page);
self.releaseRef(self._exec.context.page);
self._active_request = false;
}
@@ -208,17 +208,17 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void
self._response_headers.clearRetainingCapacity();
self._request_body = null;
const frame = self._frame;
const exec = self._exec;
self._method = try parseMethod(method_);
self._url = try URL.resolve(self._arena, frame.base(), url, .{ .always_dupe = true, .encoding = frame.charset });
try self.stateChanged(.opened, frame);
self._url = try URL.resolve(self._arena, exec.base(), url, .{ .always_dupe = true, .encoding = exec.charset.* });
try self.stateChanged(.opened, exec);
}
pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const u8, frame: *Frame) !void {
pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const u8, exec: *const Execution) !void {
if (self._ready_state != .opened) {
return error.InvalidStateError;
}
return self._request_headers.append(name, value, &frame.js.execution);
return self._request_headers.append(name, value, exec);
}
pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
@@ -235,21 +235,22 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
}
}
const frame = self._frame;
const exec = self._exec;
if (std.mem.startsWith(u8, self._url, "blob:")) {
return self.handleBlobUrl(frame);
return self.handleBlobUrl(exec);
}
const http_client = frame._session.browser.http_client;
const session = exec.context.page.session;
const http_client = &session.browser.http_client;
var headers = try http_client.newHeaders();
// Only add cookies for same-origin or when withCredentials is true
const cookie_support = self._with_credentials or frame.isSameOrigin(self._url);
const cookie_support = self._with_credentials or exec.isSameOrigin(self._url);
try self._request_headers.populateHttpHeader(frame.call_arena, &headers);
try self._request_headers.populateHttpHeader(exec.call_arena, &headers);
if (cookie_support) {
try frame.headersForRequest(&headers);
try exec.headersForRequest(&headers);
}
self.acquireRef();
@@ -261,14 +262,14 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
.url = self._url,
.method = self._method,
.headers = headers,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.frame_id = exec.frameId(),
.loader_id = exec.loaderId(),
.body = self._request_body,
.cookie_jar = if (cookie_support) &frame._session.cookie_jar else null,
.cookie_origin = frame.url,
.cookie_jar = if (cookie_support) &session.cookie_jar else null,
.cookie_origin = exec.url.*,
.resource_type = .xhr,
.timeout_ms = self._timeout,
.notification = frame._session.notification,
.notification = session.notification,
},
.start_callback = httpStartCallback,
.header_callback = httpHeaderDoneCallback,
@@ -282,8 +283,8 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
};
}
fn handleBlobUrl(self: *XMLHttpRequest, frame: *Frame) !void {
const blob = frame.lookupBlobUrl(self._url) orelse {
fn handleBlobUrl(self: *XMLHttpRequest, exec: *const Execution) !void {
const blob = exec.lookupBlobUrl(self._url) orelse {
self.handleError(error.BlobNotFound);
return;
};
@@ -294,24 +295,24 @@ fn handleBlobUrl(self: *XMLHttpRequest, frame: *Frame) !void {
try self._response_data.appendSlice(self._arena, blob._slice);
self._response_len = blob._slice.len;
try self.stateChanged(.headers_received, frame);
try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, frame);
try self.stateChanged(.loading, frame);
try self.stateChanged(.headers_received, exec);
try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, exec);
try self.stateChanged(.loading, exec);
try self._proto.dispatch(.progress, .{
.total = self._response_len orelse 0,
.loaded = self._response_data.items.len,
}, frame);
try self.stateChanged(.done, frame);
}, exec);
try self.stateChanged(.done, exec);
const loaded = self._response_data.items.len;
try self._proto.dispatch(.load, .{
.total = loaded,
.loaded = loaded,
}, frame);
}, exec);
try self._proto.dispatch(.load_end, .{
.total = loaded,
.loaded = loaded,
}, frame);
}, exec);
}
pub fn getReadyState(self: *const XMLHttpRequest) u32 {
@@ -334,14 +335,14 @@ pub fn getResponseHeader(self: *const XMLHttpRequest, name: []const u8) ?[]const
return null;
}
pub fn getAllResponseHeaders(self: *const XMLHttpRequest, frame: *Frame) ![]const u8 {
pub fn getAllResponseHeaders(self: *const XMLHttpRequest, exec: *const Execution) ![]const u8 {
if (self._ready_state != .done) {
// MDN says this should return null, but it seems to return an empty string
// in every browser. Specs are too hard for a dumbo like me to understand.
return "";
}
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
var buf = std.Io.Writer.Allocating.init(exec.call_arena);
for (self._response_headers.items) |entry| {
try buf.writer.writeAll(entry);
try buf.writer.writeAll("\r\n");
@@ -378,7 +379,7 @@ pub fn getResponseURL(self: *XMLHttpRequest) []const u8 {
return self._response_url;
}
pub fn getResponse(self: *XMLHttpRequest, frame: *Frame) !?Response {
pub fn getResponse(self: *XMLHttpRequest, exec: *const Execution) !?Response {
if (self._ready_state != .done) {
return null;
}
@@ -392,13 +393,20 @@ pub fn getResponse(self: *XMLHttpRequest, frame: *Frame) !?Response {
const res: Response = switch (self._response_type) {
.text => .{ .text = data },
.json => blk: {
const value = try frame.js.local.?.parseJSON(data);
const value = try exec.context.local.?.parseJSON(data);
break :blk .{ .json = try value.persist() };
},
.document => blk: {
const document = try frame._factory.node(Node.Document{ ._proto = undefined, ._type = .generic });
try frame.parseHtmlAsChildren(document.asNode(), data);
break :blk .{ .document = document };
// responseType=document is only meaningful in a Frame; workers
// have no DOM. Drastically different impls -> switch on global.
switch (exec.context.global) {
.frame => |frame| {
const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic });
try frame.parseHtmlAsChildren(document.asNode(), data);
break :blk .{ .document = document };
},
.worker => return error.NotSupportedInWorker,
}
},
.arraybuffer => .{ .arraybuffer = .{ .values = data } },
};
@@ -407,8 +415,8 @@ pub fn getResponse(self: *XMLHttpRequest, frame: *Frame) !?Response {
return res;
}
pub fn getResponseXML(self: *XMLHttpRequest, frame: *Frame) !?*Node.Document {
const res = (try self.getResponse(frame)) orelse return null;
pub fn getResponseXML(self: *XMLHttpRequest, exec: *const Execution) !?*Node.Document {
const res = (try self.getResponse(exec)) orelse return null;
return switch (res) {
.document => |doc| doc,
else => null,
@@ -464,15 +472,15 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool {
}
self._response_url = try self._arena.dupeZ(u8, response.url());
const frame = self._frame;
const exec = self._exec;
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
exec.context.localScope(&ls);
defer ls.deinit();
try self.stateChanged(.headers_received, frame);
try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, frame);
try self.stateChanged(.loading, frame);
try self.stateChanged(.headers_received, exec);
try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, exec);
try self.stateChanged(.loading, exec);
return true;
}
@@ -481,12 +489,10 @@ fn httpDataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
try self._response_data.appendSlice(self._arena, data);
const frame = self._frame;
try self._proto.dispatch(.progress, .{
.total = self._response_len orelse 0,
.loaded = self._response_data.items.len,
}, frame);
}, self._exec);
}
fn httpDoneCallback(ctx: *anyopaque) !void {
@@ -503,19 +509,19 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
// object. It isn't safe to keep it around.
self._http_response = null;
const frame = self._frame;
const exec = self._exec;
try self.stateChanged(.done, frame);
try self.stateChanged(.done, exec);
const loaded = self._response_data.items.len;
try self._proto.dispatch(.load, .{
.total = loaded,
.loaded = loaded,
}, frame);
}, exec);
try self._proto.dispatch(.load_end, .{
.total = loaded,
.loaded = loaded,
}, frame);
}, exec);
self.releaseSelfRef();
}
@@ -559,18 +565,18 @@ fn _handleError(self: *XMLHttpRequest, err: anyerror) !void {
const new_state: ReadyState = if (is_abort) .unsent else .done;
if (new_state != self._ready_state) {
const frame = self._frame;
const exec = self._exec;
try self.stateChanged(new_state, frame);
try self.stateChanged(new_state, exec);
if (is_abort) {
try self._proto.dispatch(.abort, null, frame);
try self._proto.dispatch(.abort, null, exec);
} else if (is_timeout) {
try self._proto.dispatch(.timeout, null, frame);
try self._proto.dispatch(.timeout, null, exec);
}
if (!is_timeout) {
try self._proto.dispatch(.err, null, frame);
try self._proto.dispatch(.err, null, exec);
}
try self._proto.dispatch(.load_end, null, frame);
try self._proto.dispatch(.load_end, null, exec);
}
const level: log.Level = if (err == error.Abort) .debug else .err;
@@ -581,7 +587,7 @@ fn _handleError(self: *XMLHttpRequest, err: anyerror) !void {
});
}
fn stateChanged(self: *XMLHttpRequest, state: ReadyState, frame: *Frame) !void {
fn stateChanged(self: *XMLHttpRequest, state: ReadyState, exec: *const Execution) !void {
if (state == self._ready_state) {
return;
}
@@ -589,9 +595,9 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, frame: *Frame) !void {
self._ready_state = state;
const target = self.asEventTarget();
if (frame._event_manager.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) {
const event = try Event.initTrusted(.wrap("readystatechange"), .{}, frame._page);
try frame._event_manager.dispatchDirect(target, event, self._on_ready_state_change, .{ .context = "XHR state change" });
if (exec.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) {
const event = try Event.initTrusted(.wrap("readystatechange"), .{}, exec.context.page);
try exec.dispatch(target, event, self._on_ready_state_change, .{ .context = "XHR state change" });
}
}

View File

@@ -18,10 +18,11 @@
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const EventTarget = @import("../EventTarget.zig");
const ProgressEvent = @import("../event/ProgressEvent.zig");
const Execution = js.Execution;
const XMLHttpRequestEventTarget = @This();
_type: Type,
@@ -43,7 +44,7 @@ pub fn asEventTarget(self: *XMLHttpRequestEventTarget) *EventTarget {
return self._proto;
}
pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchType, progress_: ?Progress, frame: *Frame) !void {
pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchType, progress_: ?Progress, exec: *const Execution) !void {
const field, const typ = comptime blk: {
break :blk switch (event_type) {
.abort => .{ "_on_abort", "abort" },
@@ -60,10 +61,10 @@ pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchT
const event = (try ProgressEvent.initTrusted(
comptime .wrap(typ),
.{ .total = progress.total, .loaded = progress.loaded },
frame,
exec.context.page,
)).asEvent();
return frame._event_manager.dispatchDirect(
return exec.dispatch(
self.asEventTarget(),
event,
@field(self, field),

View File

@@ -19,8 +19,8 @@
const std = @import("std");
const lp = @import("lightpanda");
const App = @import("../App.zig");
const Notification = @import("../Notification.zig");
const Client = @import("../Server.zig").Client;
const js = @import("../browser/js/js.zig");
const Browser = @import("../browser/Browser.zig");
const Session = @import("../browser/Session.zig");
@@ -28,12 +28,16 @@ const Frame = @import("../browser/Frame.zig");
const Mime = @import("../browser/Mime.zig");
const Element = @import("../browser/webapi/Element.zig");
const Label = @import("../browser/webapi/element/html/Label.zig");
const Request = @import("../browser/HttpClient.zig").Request;
const CDPClient = @import("../browser/HttpClient.zig").CDPClient;
const WsConnection = @import("../network/WsConnection.zig");
const Incrementing = @import("id.zig").Incrementing;
const InterceptState = @import("domains/fetch.zig").InterceptState;
const log = lp.log;
const json = std.json;
const posix = std.posix;
const Allocator = std.mem.Allocator;
pub const URL_BASE = "chrome://newtab/";
@@ -47,10 +51,10 @@ const BrowserContextIdGen = Incrementing(u32, "BID");
// Generic so that we can inject mocks into it.
const CDP = @This();
// Used for sending message to the client and closing on error
client: *Client,
allocator: Allocator,
app: *App,
ws: WsConnection,
// The active browser
browser: Browser,
@@ -78,18 +82,18 @@ frame_arena: std.heap.ArenaAllocator,
// (or altogether eliminate) our use of this.
browser_context_arena: std.heap.ArenaAllocator,
pub fn init(client: *Client) !CDP {
const app = client.app;
pub fn init(
self: *CDP,
app: *App,
socket: posix.socket_t,
json_version_response: []const u8,
) !void {
const allocator = app.allocator;
const browser = try Browser.init(app, .{
.env = .{ .with_inspector = true },
.http_client = client.http,
});
errdefer browser.deinit();
return .{
.client = client,
.browser = browser,
self.* = .{
.app = app,
.ws = undefined,
.browser = undefined,
.allocator = allocator,
.browser_context = null,
.frame_arena = std.heap.ArenaAllocator.init(allocator),
@@ -97,6 +101,17 @@ pub fn init(client: *Client) !CDP {
.notification_arena = std.heap.ArenaAllocator.init(allocator),
.browser_context_arena = std.heap.ArenaAllocator.init(allocator),
};
try self.ws.init(socket, self.app.allocator, json_version_response);
errdefer self.ws.deinit();
try self.browser.init(app, .{ .env = .{ .with_inspector = true } }, .{
.ctx = self,
.socket = socket,
.blocking_read_start = CDP.blockingReadStart,
.blocking_read = CDP.blockingRead,
.blocking_read_end = CDP.blockingReadStop,
});
}
pub fn deinit(self: *CDP) void {
@@ -108,6 +123,48 @@ pub fn deinit(self: *CDP) void {
self.message_arena.deinit();
self.notification_arena.deinit();
self.browser_context_arena.deinit();
self.ws.deinit();
}
pub fn blockingReadStart(ctx: *anyopaque) bool {
const self: *CDP = @ptrCast(@alignCast(ctx));
self.ws.setBlocking(true) catch |err| {
log.warn(.app, "CDP blockingReadStart", .{ .err = err });
return false;
};
return true;
}
pub fn blockingRead(ctx: *anyopaque) bool {
const self: *CDP = @ptrCast(@alignCast(ctx));
return self.readSocket();
}
pub fn blockingReadStop(ctx: *anyopaque) bool {
const self: *CDP = @ptrCast(@alignCast(ctx));
self.ws.setBlocking(false) catch |err| {
log.warn(.app, "CDP blockingReadStop", .{ .err = err });
return false;
};
return true;
}
pub fn readSocket(self: *CDP) bool {
const n = self.ws.read() catch |err| {
log.warn(.app, "CDP read", .{ .err = err });
return false;
};
if (n == 0) {
log.info(.app, "CDP disconnect", .{});
return false;
}
return self.ws.processMessages(self) catch false;
}
pub fn sendJSON(self: *CDP, message: anytype) !void {
try self.ws.sendJSON(message, .{ .emit_null_optional_fields = false });
}
pub fn handleMessage(self: *CDP, msg: []const u8) bool {
@@ -132,6 +189,29 @@ pub fn pageWait(self: *CDP, ms: u32) !Session.Runner.CDPWaitResult {
return runner.waitCDP(.{ .ms = ms });
}
pub fn tick(self: *CDP) !bool {
// Liveness is enforced by TCP keepalive configured in
// Network.acceptConnections; the wakeup lets V8 run or terminate.
const wait_ms: u32 = 1000; // 1s
const result = self.pageWait(wait_ms) catch |wait_err| switch (wait_err) {
error.NoPage => {
const status = self.browser.http_client.tick(wait_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return false;
};
return status != .cdp_socket or self.readSocket();
},
else => return wait_err,
};
if (result == .cdp_socket) {
return self.readSocket();
}
return true;
}
// Called from above, in processMessage which handles client messages
// but can also be called internally. For example, Target.sendMessageToTarget
// calls back into dispatch to capture the response.
@@ -223,11 +303,11 @@ fn dispatchCommand(command: *Command, method: []const u8) !void {
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
asUint(u40, "Audit") => return @import("domains/audit.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
asUint(u48, "Audits") => return @import("domains/audits.zig").processMessage(command),
else => {},
},
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
@@ -302,12 +382,6 @@ pub fn sendEvent(self: *CDP, method: []const u8, p: anytype, opts: SendEventOpts
});
}
pub fn sendJSON(self: *CDP, message: anytype) !void {
return self.client.sendJSON(message, .{
.emit_null_optional_fields = false,
});
}
pub const BrowserContext = struct {
const Node = @import("Node.zig");
const AXNode = @import("AXNode.zig");
@@ -317,6 +391,18 @@ pub const BrowserContext = struct {
data: std.ArrayList(u8),
};
// Key for `captured_responses`. Documents are keyed by `loader_id`,
// everything else by `request_id` — the two id-spaces are independent
// counters and overlap numerically (loader 1 / request 1, loader 2 /
// request 2, ...), so the map key has to carry the namespace or
// entries collide. The wire-format prefix (`LID-` / `REQ-`) provides
// the same disambiguation on lookup; see `idFromRequestId` in
// domains/network.zig.
pub const CapturedResponseKey = struct {
kind: enum { request, loader },
id: u32,
};
id: []const u8,
cdp: *CDP,
@@ -382,7 +468,7 @@ pub const BrowserContext = struct {
// ever streamed. So if CDP is the only thing that needs bodies in
// memory for an arbitrary amount of time, then that's where we're going
// to store the,
captured_responses: std.AutoHashMapUnmanaged(usize, CapturedResponse),
captured_responses: std.AutoHashMapUnmanaged(CapturedResponseKey, CapturedResponse),
notification: *Notification,
@@ -401,7 +487,7 @@ pub const BrowserContext = struct {
errdefer notification.deinit();
const session = try cdp.browser.newSession(notification);
if (cdp.client.app.config.cookieFile()) |cookie_path| {
if (cdp.app.config.cookieFile()) |cookie_path| {
lp.cookies.loadFromFile(session, cookie_path);
}
@@ -457,7 +543,7 @@ pub const BrowserContext = struct {
// abort all intercepted requests before closing the session/page
// since some of these might callback into the page/scriptmanager
const http_client = browser.http_client;
const http_client = &browser.http_client;
for (self.intercept_state.pendingIntercepts()) |intercept| {
defer {
lp.assert(
@@ -685,6 +771,13 @@ pub const BrowserContext = struct {
return @import("domains/page.zig").javascriptDialogOpening(self, msg);
}
fn keyFromRequestReq(req: *const Request) CDP.BrowserContext.CapturedResponseKey {
return if (req.params.resource_type == .document)
.{ .kind = .loader, .id = req.params.loader_id }
else
.{ .kind = .request, .id = req.params.request_id };
}
pub fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
defer self.resetNotificationArena();
@@ -692,8 +785,8 @@ pub const BrowserContext = struct {
const arena = self.frame_arena;
// Prepare the captured response value.
const id = msg.request.params.request_id;
const gop = try self.captured_responses.getOrPut(arena, id);
const key = keyFromRequestReq(msg.request);
const gop = try self.captured_responses.getOrPut(arena, key);
if (!gop.found_existing) {
gop.value_ptr.* = .{
.data = .empty,
@@ -729,8 +822,8 @@ pub const BrowserContext = struct {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
const arena = self.frame_arena;
const id = msg.request.params.request_id;
const resp = self.captured_responses.getPtr(id) orelse lp.assert(false, "onHttpResponseData missinf captured response", .{});
const key = keyFromRequestReq(msg.request);
const resp = self.captured_responses.getPtr(key) orelse lp.assert(false, "onHttpResponseData missing captured response", .{});
return resp.data.appendSlice(arena, msg.data);
}
@@ -784,7 +877,7 @@ pub const BrowserContext = struct {
};
const cdp = self.cdp;
const allocator = cdp.client.sendAllocator();
const allocator = cdp.ws.send_arena.allocator();
const field = ",\"sessionId\":\"";
@@ -810,7 +903,7 @@ pub const BrowserContext = struct {
std.debug.assert(buf.items.len == message_len);
}
try cdp.client.sendJSONRaw(buf);
try cdp.ws.sendJSONRaw(buf);
}
};

View File

@@ -98,7 +98,7 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void {
};
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const http_client = cmd.cdp.browser.http_client;
const http_client = &cmd.cdp.browser.http_client;
try http_client.setUserAgentOverride(ua);
bc.user_agent_changed = true;

View File

@@ -286,7 +286,7 @@ fn continueRequest(cmd: *CDP.Command) !void {
}
// todo: replace.
const client = bc.cdp.browser.http_client;
const client = &bc.cdp.browser.http_client;
try client.interception_layer.continueRequest(client, request);
return cmd.sendResult(null, .{});
}
@@ -321,7 +321,7 @@ fn continueWithAuth(cmd: *CDP.Command) !void {
.response = params.authChallengeResponse.response,
});
const client = bc.cdp.browser.http_client;
const client = &bc.cdp.browser.http_client;
if (params.authChallengeResponse.response != .ProvideCredentials) {
transfer.abortAuthChallenge();
@@ -385,7 +385,7 @@ fn fulfillRequest(cmd: *CDP.Command) !void {
body = buf;
}
const client = bc.cdp.browser.http_client;
const client = &bc.cdp.browser.http_client;
try client.interception_layer.fulfillRequest(client, request, params.responseCode, params.responseHeaders orelse &.{}, body);
return cmd.sendResult(null, .{});
}
@@ -403,7 +403,7 @@ fn failRequest(cmd: *CDP.Command) !void {
const pending = intercept_state.remove(request_id) orelse return error.RequestNotFound;
const request = pending.request;
const client = bc.cdp.browser.http_client;
const client = &bc.cdp.browser.http_client;
defer client.interception_layer.abortRequest(client, request);
log.info(.cdp, "request intercept", .{

View File

@@ -229,9 +229,9 @@ fn getResponseBody(cmd: *CDP.Command) !void {
requestId: []const u8, // "REQ-{d}" or "LID-{d}"
})) orelse return error.InvalidParams;
const request_id = try idFromRequestId(params.requestId);
const key = try keyFromRequestId(params.requestId);
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const resp = bc.captured_responses.getPtr(request_id) orelse return error.RequestNotFound;
const resp = bc.captured_responses.getPtr(key) orelse return error.RequestNotFound;
if (!resp.must_encode) {
return cmd.sendResult(.{
@@ -258,7 +258,7 @@ pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.Request
// Isn't possible to do a network request within a Browser (which our
// notification is tied to), without a frame.
lp.assert(bc.session.page != null, "CDP.network.httpRequestFail null frame", .{});
lp.assert(bc.session.hasPage(), "CDP.network.httpRequestFail null frame", .{});
// We're missing a bunch of fields, but, for now, this seems like enough
try bc.cdp.sendEvent("Network.loadingFailed", .{
@@ -476,12 +476,13 @@ const ResponseWriter = struct {
}
};
fn idFromRequestId(request_id: []const u8) !u64 {
// The requesIid for the original document is its loaderId.
if (!std.mem.startsWith(u8, request_id, "REQ-") and !std.mem.startsWith(u8, request_id, "LID-")) {
return error.InvalidParams;
}
return std.fmt.parseInt(u64, request_id[4..], 10) catch return error.InvalidParams;
fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedResponseKey {
const key = std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams;
return if (std.mem.startsWith(u8, request_id, "LID-"))
.{ .id = key, .kind = .loader }
else
.{ .id = key, .kind = .request };
}
const testing = @import("../testing.zig");

View File

@@ -212,7 +212,7 @@ fn close(cmd: *CDP.Command) !void {
const target_id = bc.target_id orelse return error.TargetNotLoaded;
// can't be null if we have a target_id
lp.assert(bc.session.page != null, "CDP.frame.close null frame", .{});
lp.assert(bc.session.hasPage(), "CDP.frame.close null frame", .{});
try cmd.sendResult(.{}, .{});
@@ -298,20 +298,10 @@ fn navigate(cmd: *CDP.Command) !void {
}
const session = bc.session;
var frame = session.currentFrame() orelse return error.FrameNotLoaded;
if (frame._load_state != .waiting) {
// Reset isolated world identities to disable V8 weak callbacks before
// resetPageResources releases refs. Prevents double-release crashes.
for (bc.isolated_worlds.items) |isolated_world| {
isolated_world.identity.deinit();
isolated_world.identity = .{};
}
frame = try session.replacePage();
}
const frame = session.currentFrame() orelse return error.FrameNotLoaded;
const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8");
try frame.navigate(encoded_url, .{
try session.initiateRootNavigation(frame._frame_id, encoded_url, .{
.reason = .address_bar,
.cdp_id = cmd.input.id,
.kind = .{ .push = null },
@@ -331,10 +321,10 @@ fn doReload(cmd: *CDP.Command) !void {
}
const session = bc.session;
var frame = session.currentFrame() orelse return error.FrameNotLoaded;
const frame = session.currentFrame() orelse return error.FrameNotLoaded;
// Capture URL plus the prior navigation's method/body/header before
// replacePage() frees the old frame's arena. Replaying the same HTTP
// we free the old frame's arena. Replaying the same HTTP
// method on reload matches Chrome's F5 behavior — POST navigations
// re-submit, GET navigations re-fetch.
const reload_url = try cmd.arena.dupeZ(u8, frame.url);
@@ -347,17 +337,7 @@ fn doReload(cmd: *CDP.Command) !void {
};
};
if (frame._load_state != .waiting) {
// Reset isolated world identities to disable V8 weak callbacks before
// resetPageResources releases refs. Prevents double-release crashes.
for (bc.isolated_worlds.items) |isolated_world| {
isolated_world.identity.deinit();
isolated_world.identity = .{};
}
frame = try session.replacePage();
}
try frame.navigate(reload_url, .{
try session.initiateRootNavigation(frame._frame_id, reload_url, .{
.reason = .address_bar,
.cdp_id = cmd.input.id,
.kind = .reload,
@@ -372,7 +352,14 @@ pub fn frameNavigate(bc: *CDP.BrowserContext, event: *const Notification.FrameNa
// detachTarget could be called, in which case, we still have a frame doing
// things, but no session.
const session_id = bc.session_id orelse return;
bc.reset();
// is_pending_root means this navigation is in flight against a pending
// Page while the OLD page is still alive and addressable. Don't blow
// away the node_registry — the OLD page's nodes are still referenced
// by client-held objectIds. The reset moves to frameRemove (commit).
if (!event.is_pending_root) {
bc.reset();
}
const frame_id = &id.toFrameId(event.frame_id);
const loader_id = &id.toLoaderId(event.loader_id);
@@ -429,18 +416,40 @@ pub fn frameRemove(bc: *CDP.BrowserContext) void {
for (bc.isolated_worlds.items) |isolated_world| {
isolated_world.removeContext();
}
// node_registry / node_search_list reference Nodes from the page being
// torn down — clear them before the page's memory is freed. For pending
// root commits this is the only reset, because frameNavigate set
// is_pending_root=true and deliberately skipped its own reset so the
// OLD page's nodes stayed addressable during the in-flight HTTP. For
// synthetic / non-pending navs frameNavigate also calls bc.reset()
// (via the !is_pending_root branch); the two are redundant but harmless.
bc.reset();
}
pub fn frameCreated(bc: *CDP.BrowserContext, frame: *Frame) !void {
_ = bc.cdp.frame_arena.reset(.{ .retain_with_limit = 1024 * 512 });
// Detect "in commit" mode: Session.commitPendingPage dispatches frame_
// created BEFORE clearing pending_page (deliberate ordering — see
// Session.commitPendingPage). The captured_response for the request we
// just committed was inserted by onHttpResponseHeadersDone moments ago
// and lives in cdp.frame_arena; resetting either would lose it.
const in_commit = bc.session.pendingPage() != null;
if (!in_commit) {
_ = bc.cdp.frame_arena.reset(.{ .retain_with_limit = 1024 * 512 });
}
for (bc.isolated_worlds.items) |isolated_world| {
_ = try isolated_world.createContext(frame);
}
// Only retain captured responses until a navigation event. In CDP term,
// this is called a "renderer" and the cache-duration can be controlled via
// the Network.configureDurableMessages message (which we don't support)
bc.captured_responses = .empty;
if (!in_commit) {
// Only retain captured responses until a navigation event. In CDP
// terms, this is called a "renderer" and the cache-duration can be
// controlled via Network.configureDurableMessages (which we don't
// support).
bc.captured_responses = .empty;
}
}
pub fn frameChildFrameCreated(bc: *CDP.BrowserContext, event: *const Notification.FrameChildFrameCreated) !void {
@@ -1016,17 +1025,21 @@ test "cdp.frame: reload" {
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 30 });
}
_ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
{
// reload with no params — should not error (navigation is async,
// so no result is sent synchronously; we just verify no error)
try ctx.processMessage(.{ .id = 31, .method = "Page.reload" });
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
}
{
// reload with ignoreCache param
try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
}
}

View File

@@ -171,7 +171,7 @@ fn createTarget(cmd: *CDP.Command) !void {
}
// if target_id is null, we should never have a blank frame
lp.assert(bc.session.page == null, "CDP.target.createTarget not null page", .{});
lp.assert(!bc.session.hasPage(), "CDP.target.createTarget not null page", .{});
// if target_id is null, we should never have a session_id
lp.assert(bc.session_id == null, "CDP.target.createTarget not null session_id", .{});
@@ -284,7 +284,7 @@ fn closeTarget(cmd: *CDP.Command) !void {
}
// can't be null if we have a target_id
lp.assert(bc.session.page != null, "CDP.target.closeTarget null frame", .{});
lp.assert(bc.session.hasPage(), "CDP.target.closeTarget null frame", .{});
try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false });
@@ -636,7 +636,7 @@ test "cdp.target: closeTarget" {
{
try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } });
try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 });
try testing.expectEqual(null, bc.session.page);
try testing.expectEqual(false, bc.session.hasPage());
try testing.expectEqual(null, bc.target_id);
}
}

View File

@@ -22,6 +22,8 @@ const posix = std.posix;
const CDP = @import("CDP.zig");
const Server = @import("../Server.zig");
const Net = @import("../network/WsConnection.zig");
const HttpClient = @import("../browser/HttpClient.zig");
const base = @import("../testing.zig");
pub const allocator = base.allocator;
@@ -37,26 +39,25 @@ pub const LogFilter = base.LogFilter;
const TestContext = struct {
read_at: usize = 0,
read_buf: [1024 * 32]u8 = undefined,
cdp_: ?CDP = null,
client: Server.Client,
cdp_: CDP = undefined,
cdp_initialized: bool = false,
cdp_socket: posix.socket_t,
socket: posix.socket_t,
received: std.ArrayList(json.Value) = .empty,
received_raw: std.ArrayList([]const u8) = .empty,
pub fn deinit(self: *TestContext) void {
if (self.cdp_) |*c| {
c.deinit();
}
self.client.deinit();
if (self.cdp_initialized) self.cdp_.deinit();
posix.close(self.socket);
base.reset();
}
pub fn cdp(self: *TestContext) *CDP {
if (self.cdp_ == null) {
self.cdp_ = CDP.init(&self.client) catch |err| @panic(@errorName(err));
if (!self.cdp_initialized) {
self.cdp_.init(base.test_app, self.cdp_socket, "json-version") catch |err| @panic(@errorName(err));
self.cdp_initialized = true;
}
return &self.cdp_.?;
return &self.cdp_;
}
const BrowserContextOpts = struct {
@@ -202,12 +203,10 @@ const TestContext = struct {
return;
}
if (self.cdp_) |*cdp__| {
if (cdp__.browser_context) |*bc| {
if (bc.session.page != null) {
var runner = try bc.session.runner(.{});
_ = try runner.tick(.{ .ms = 1000 });
}
if (self.cdp_.browser_context) |*bc| {
if (bc.session.hasPage()) {
var runner = try bc.session.runner(.{});
_ = try runner.tick(.{ .ms = 1000 });
}
}
std.Thread.sleep(5 * std.time.ns_per_ms);
@@ -315,10 +314,8 @@ pub fn context() !TestContext {
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.RCVBUF, &std.mem.toBytes(@as(c_int, 32_768)));
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDBUF, &std.mem.toBytes(@as(c_int, 32_768)));
const client = try Server.Client.init(pair[1], base.arena_allocator, base.test_app, "json-version");
return .{
.client = client,
.cdp_socket = pair[1],
.socket = pair[0],
};
}

View File

@@ -250,29 +250,26 @@ noinline fn assertionFailure(comptime ctx: []const u8, args: anytype) noreturn {
// Reference counting helper
pub fn RC(comptime T: type) type {
return struct {
_refs: T = 0,
_refs: std.atomic.Value(T) = .init(0),
pub fn init(refs: T) @This() {
return .{ ._refs = refs };
return .{ ._refs = .init(refs) };
}
pub fn acquire(self: *@This()) void {
self._refs += 1;
_ = self._refs.fetchAdd(1, .monotonic);
}
pub fn release(self: *@This(), value: anytype, page: *Page) void {
assert(self._refs > 0, "release overflow", .{ .type = @typeName(@TypeOf(value)) });
const refs = self._refs - 1;
self._refs = refs;
if (refs > 0) {
return;
const prev = self._refs.fetchSub(1, .acq_rel);
assert(prev > 0, "release overflow", .{ .type = @typeName(@TypeOf(value)) });
if (prev == 1) {
value.deinit(page);
}
value.deinit(page);
}
pub fn format(self: @This(), writer: *std.Io.Writer) !void {
return writer.print("{d}", .{self._refs});
return writer.print("{d}", .{self._refs.load(.monotonic)});
}
};
}

View File

@@ -256,13 +256,8 @@ const FetchTerminator = struct {
fn fetchThread(app: *App, ft: *FetchTerminator, url: [:0]const u8, fetch_opts: lp.FetchOpts) void {
defer app.network.stop();
const http_client = lp.HttpClient.init(app.allocator, &app.network) catch |err| {
log.fatal(.app, "http client init error", .{ .err = err });
return;
};
defer http_client.deinit();
var browser = lp.Browser.init(app, .{ .http_client = http_client }) catch |err| {
var browser: lp.Browser = undefined;
browser.init(app, .{}, null) catch |err| {
log.fatal(.app, "browser init error", .{ .err = err });
return;
};

View File

@@ -44,10 +44,8 @@ pub fn main() !void {
var test_arena = std.heap.ArenaAllocator.init(allocator);
defer test_arena.deinit();
const http_client = try lp.HttpClient.init(allocator, &app.network);
defer http_client.deinit();
var browser = try lp.Browser.init(app, .{ .http_client = http_client });
var browser: lp.Browser = undefined;
try browser.init(app, .{}, null);
defer browser.deinit();
const notification = try lp.Notification.init(allocator);

View File

@@ -3,7 +3,6 @@ const std = @import("std");
const lp = @import("lightpanda");
const App = @import("../App.zig");
const HttpClient = @import("../browser/HttpClient.zig");
const testing = @import("../testing.zig");
const protocol = @import("protocol.zig");
const resources = @import("resources.zig");
@@ -17,7 +16,6 @@ const Self = @This();
allocator: std.mem.Allocator,
app: *App,
http_client: *HttpClient,
notification: *lp.Notification,
browser: lp.Browser,
session: *lp.Session,
@@ -26,29 +24,25 @@ node_registry: CDPNode.Registry,
transport: Transport,
pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*Self {
const http_client = try HttpClient.init(allocator, &app.network);
errdefer http_client.deinit();
const notification = try lp.Notification.init(allocator);
errdefer notification.deinit();
const self = try allocator.create(Self);
errdefer allocator.destroy(self);
var browser = try lp.Browser.init(app, .{ .http_client = http_client });
errdefer browser.deinit();
self.* = .{
.allocator = allocator,
.app = app,
.browser = browser,
.browser = undefined,
.transport = .init(allocator, writer),
.http_client = http_client,
.notification = notification,
.session = undefined,
.node_registry = CDPNode.Registry.init(allocator),
};
try self.browser.init(app, .{}, null);
errdefer self.browser.deinit();
self.session = try self.browser.newSession(self.notification);
if (app.config.cookieFile()) |cookie_path| {
@@ -67,7 +61,6 @@ pub fn deinit(self: *Self) void {
self.transport.deinit();
self.browser.deinit();
self.notification.deinit();
self.http_client.deinit();
self.allocator.destroy(self);
}

View File

@@ -250,7 +250,7 @@ test "MCP - Actions by selector: hover, selectOption, setChecked" {
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
defer server.deinit();
const page = &server.session.page.?;
const page = server.session.currentPage().?;
{
// Hover by selector

View File

@@ -70,6 +70,7 @@ ws_mutex: std.Thread.Mutex = .{},
pollfds: []posix.pollfd,
listener: ?Listener = null,
accept: std.atomic.Value(bool) = .init(true),
// Wakeup pipe: workers write to [1], main thread polls [0]
wakeup_pipe: [2]posix.fd_t = .{ -1, -1 },
@@ -355,6 +356,10 @@ pub fn bind(
ctx: *anyopaque,
on_accept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void,
) !void {
if (self.listener != null) return error.TooManyListeners;
self.accept.store(true, .release);
const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK;
const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP);
errdefer posix.close(listener);
@@ -374,8 +379,6 @@ pub fn bind(
try posix.getsockname(listener, @ptrCast(&bound), &bound_len);
address.* = net.Address.initPosix(@ptrCast(@alignCast(&bound)));
if (self.listener != null) return error.TooManyListeners;
self.listener = .{
.socket = listener,
.ctx = ctx,
@@ -388,6 +391,11 @@ pub fn bind(
};
}
pub fn unbind(self: *Network) void {
self.accept.store(false, .release);
self.wakeupPoll();
}
pub fn onTick(self: *Network, ctx: *anyopaque, callback: *const fn (*anyopaque) void) void {
self.callbacks_mutex.lock();
defer self.callbacks_mutex.unlock();
@@ -424,6 +432,12 @@ pub fn run(self: *Network) void {
// telemetry, but we stop accepting new connections. It is the responsibility
// of external code to terminate its requests upon shutdown.
while (true) {
if (self.listener != null and !self.accept.load(.acquire)) {
posix.close(self.listener.?.socket);
self.listener = null;
self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 };
}
self.drainQueue();
if (self.multi) |multi| {
@@ -574,6 +588,28 @@ fn acceptConnections(self: *Network) void {
}
};
// Liveness is enforced at the TCP layer via keepalive probes sent by the
// kernel. This is transparent to CDP clients — unlike a WebSocket ping, which
// go-rod panics on and chromedp logs as "malformed". Tunables in Config.zig.
posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.KEEPALIVE, &std.mem.toBytes(@as(c_int, 1))) catch |err| {
log.warn(.app, "SO_KEEPALIVE", .{ .err = err });
return;
};
const option = switch (@import("builtin").os.tag) {
.macos, .ios => posix.TCP.KEEPALIVE,
else => posix.TCP.KEEPIDLE,
};
posix.setsockopt(socket, posix.IPPROTO.TCP, option, &std.mem.toBytes(Config.CDP_KEEPALIVE_IDLE_S)) catch |err| {
log.warn(.app, "TCP_KEEPIDLE", .{ .err = err });
};
posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, &std.mem.toBytes(Config.CDP_KEEPALIVE_INTVL_S)) catch |err| {
log.warn(.app, "TCP_KEEPINTVL", .{ .err = err });
};
posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPCNT, &std.mem.toBytes(Config.CDP_KEEPALIVE_CNT)) catch |err| {
log.warn(.app, "TCP_KEEPCNT", .{ .err = err });
};
listener.onAccept(listener.ctx, socket);
}
}

View File

@@ -24,7 +24,8 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const log = @import("lightpanda").log;
const assert = @import("lightpanda").assert;
const CDP_MAX_MESSAGE_SIZE = @import("../Config.zig").CDP_MAX_MESSAGE_SIZE;
const Config = @import("../Config.zig");
const CDP_MAX_MESSAGE_SIZE = Config.CDP_MAX_MESSAGE_SIZE;
const Fragments = struct {
type: Message.Type,
@@ -305,301 +306,429 @@ pub fn Reader(comptime EXPECT_MASK: bool) type {
};
}
pub const WsConnection = struct {
// CLOSE, 2 length, code
const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000
const CLOSE_GOING_AWAY = [_]u8{ 136, 2, 3, 233 }; // code: 1001
const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009
const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002
// "private-use" close codes must be from 4000-49999
const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000
pub const WsConnection = @This();
// CLOSE, 2 length, code
const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000
const CLOSE_GOING_AWAY = [_]u8{ 136, 2, 3, 233 }; // code: 1001
const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009
const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002
// "private-use" close codes must be from 4000-49999
const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000
socket: posix.socket_t,
socket_flags: usize,
reader: Reader(true),
send_arena: ArenaAllocator,
json_version_response: []const u8,
pub fn init(
self: *WsConnection,
socket: posix.socket_t,
socket_flags: usize,
reader: Reader(true),
send_arena: ArenaAllocator,
allocator: Allocator,
json_version_response: []const u8,
) !void {
const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0);
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
if (builtin.is_test == false) {
assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{});
}
pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8) !WsConnection {
const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0);
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
if (builtin.is_test == false) {
assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{});
}
var reader = try Reader(true).init(allocator);
errdefer reader.deinit();
var reader = try Reader(true).init(allocator);
errdefer reader.deinit();
self.* = .{
.socket = socket,
.socket_flags = socket_flags,
.reader = reader,
.send_arena = ArenaAllocator.init(allocator),
.json_version_response = json_version_response,
};
}
return .{
.socket = socket,
.socket_flags = socket_flags,
.reader = reader,
.send_arena = ArenaAllocator.init(allocator),
.json_version_response = json_version_response,
pub fn deinit(self: *WsConnection) void {
self.reader.deinit();
self.send_arena.deinit();
}
pub fn send(self: *WsConnection, data: []const u8) !void {
var pos: usize = 0;
var changed_to_blocking: bool = false;
defer _ = self.send_arena.reset(.{ .retain_with_limit = 1024 * 32 });
defer if (changed_to_blocking) {
// We had to change our socket to blocking me to get our write out
// We need to change it back to non-blocking.
_ = posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags) catch |err| {
log.err(.app, "ws restore nonblocking", .{ .err = err });
};
}
};
pub fn deinit(self: *WsConnection) void {
self.reader.deinit();
self.send_arena.deinit();
}
pub fn send(self: *WsConnection, data: []const u8) !void {
var pos: usize = 0;
var changed_to_blocking: bool = false;
defer _ = self.send_arena.reset(.{ .retain_with_limit = 1024 * 32 });
defer if (changed_to_blocking) {
// We had to change our socket to blocking me to get our write out
// We need to change it back to non-blocking.
_ = posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags) catch |err| {
log.err(.app, "ws restore nonblocking", .{ .err = err });
};
LOOP: while (pos < data.len) {
const written = posix.write(self.socket, data[pos..]) catch |err| switch (err) {
error.WouldBlock => {
// self.socket is nonblocking, because we don't want to block
// reads. But our life is a lot easier if we block writes,
// largely, because we don't have to maintain a queue of pending
// writes (which would each need their own allocations). So
// if we get a WouldBlock error, we'll switch the socket to
// blocking and switch it back to non-blocking after the write
// is complete. Doesn't seem particularly efficiently, but
// this should virtually never happen.
assert(changed_to_blocking == false, "WsConnection.double block", .{});
changed_to_blocking = true;
_ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true })));
continue :LOOP;
},
else => return err,
};
LOOP: while (pos < data.len) {
const written = posix.write(self.socket, data[pos..]) catch |err| switch (err) {
error.WouldBlock => {
// self.socket is nonblocking, because we don't want to block
// reads. But our life is a lot easier if we block writes,
// largely, because we don't have to maintain a queue of pending
// writes (which would each need their own allocations). So
// if we get a WouldBlock error, we'll switch the socket to
// blocking and switch it back to non-blocking after the write
// is complete. Doesn't seem particularly efficiently, but
// this should virtually never happen.
assert(changed_to_blocking == false, "WsConnection.double block", .{});
changed_to_blocking = true;
_ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true })));
continue :LOOP;
},
else => return err,
};
if (written == 0) {
return error.Closed;
}
pos += written;
if (written == 0) {
return error.Closed;
}
pos += written;
}
}
const EMPTY_PONG = [_]u8{ 138, 0 };
const EMPTY_PONG = [_]u8{ 138, 0 };
fn sendPong(self: *WsConnection, data: []const u8) !void {
if (data.len == 0) {
return self.send(&EMPTY_PONG);
fn sendPong(self: *WsConnection, data: []const u8) !void {
if (data.len == 0) {
return self.send(&EMPTY_PONG);
}
var header_buf: [10]u8 = undefined;
const header = websocketHeader(&header_buf, .pong, data.len);
const allocator = self.send_arena.allocator();
const framed = try allocator.alloc(u8, header.len + data.len);
@memcpy(framed[0..header.len], header);
@memcpy(framed[header.len..], data);
return self.send(framed);
}
// called by CDP
// Websocket frames have a variable length header. For server-client,
// it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have
// writev, so we need to get creative. We'll JSON serialize to a
// buffer, where the first 10 bytes are reserved. We can then backfill
// the header and send the slice.
pub fn sendJSON(self: *WsConnection, message: anytype, opts: std.json.Stringify.Options) !void {
const allocator = self.send_arena.allocator();
var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512);
// reserve space for the maximum possible header
try aw.writer.writeAll(&[_]u8{0} ** 10);
try std.json.Stringify.value(message, opts, &aw.writer);
const framed = fillWebsocketHeader(aw.toArrayList());
return self.send(framed);
}
pub fn sendJSONRaw(
self: *WsConnection,
buf: std.ArrayList(u8),
) !void {
// Dangerous API!. We assume the caller has reserved the first 10
// bytes in `buf`.
const framed = fillWebsocketHeader(buf);
return self.send(framed);
}
pub const HttpResult = enum { more, upgraded, close };
pub fn handshake(self: *WsConnection) !bool {
// Liveness is enforced by TCP keepalive configured in
// Server.setTcpKeepalive; a dead peer surfaces as a poll error or
// EOF from read(). The poll blocks for ~24 days rather than tracking
// an app-level timeout. Capped at i32-max because posix.poll narrows
// to c_int.
const wait_ms: i32 = std.math.maxInt(i32);
while (true) {
var pfds = [_]posix.pollfd{.{
.fd = self.socket,
.events = posix.POLL.IN,
.revents = 0,
}};
const n = try posix.poll(&pfds, wait_ms);
if (n == 0) {
log.info(.app, "CDP timeout", .{});
return false;
}
var header_buf: [10]u8 = undefined;
const header = websocketHeader(&header_buf, .pong, data.len);
const allocator = self.send_arena.allocator();
const framed = try allocator.alloc(u8, header.len + data.len);
@memcpy(framed[0..header.len], header);
@memcpy(framed[header.len..], data);
return self.send(framed);
}
// called by CDP
// Websocket frames have a variable length header. For server-client,
// it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have
// writev, so we need to get creative. We'll JSON serialize to a
// buffer, where the first 10 bytes are reserved. We can then backfill
// the header and send the slice.
pub fn sendJSON(self: *WsConnection, message: anytype, opts: std.json.Stringify.Options) !void {
const allocator = self.send_arena.allocator();
var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512);
// reserve space for the maximum possible header
try aw.writer.writeAll(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
try std.json.Stringify.value(message, opts, &aw.writer);
const framed = fillWebsocketHeader(aw.toArrayList());
return self.send(framed);
}
pub fn sendJSONRaw(
self: *WsConnection,
buf: std.ArrayList(u8),
) !void {
// Dangerous API!. We assume the caller has reserved the first 10
// bytes in `buf`.
const framed = fillWebsocketHeader(buf);
return self.send(framed);
}
pub fn read(self: *WsConnection) !usize {
const n = try posix.read(self.socket, self.reader.readBuf());
self.reader.len += n;
return n;
}
pub fn processMessages(self: *WsConnection, handler: anytype) !bool {
var reader = &self.reader;
while (true) {
const msg = reader.next() catch |err| {
switch (err) {
error.TooLarge => self.send(&CLOSE_TOO_BIG) catch {},
error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.ControlTooLarge => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.InvalidContinuation => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.NestedFragmentation => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.OutOfMemory => {}, // don't borther trying to send an error in this case
}
return err;
} orelse break;
switch (msg.type) {
.pong => {},
.ping => try self.sendPong(msg.data),
.close => {
self.send(&CLOSE_NORMAL) catch {};
return false;
},
.text, .binary => if (handler.handleMessage(msg.data) == false) {
return false;
},
}
if (msg.cleanup_fragment) {
reader.cleanup();
}
}
// We might have read part of the next message. Our reader potentially
// has to move data around in its buffer to make space.
reader.compact();
return true;
}
pub fn upgrade(self: *WsConnection, request: []u8) !void {
// our caller already confirmed that we have a trailing \r\n\r\n
const request_line_end = std.mem.indexOfScalar(u8, request, '\r') orelse unreachable;
const request_line = request[0..request_line_end];
if (!std.ascii.endsWithIgnoreCase(request_line, "http/1.1")) {
return error.InvalidProtocol;
}
// we need to extract the sec-websocket-key value
var key: []const u8 = "";
// we need to make sure that we got all the necessary headers + values
var required_headers: u8 = 0;
// can't std.mem.split because it forces the iterated value to be const
// (we could @constCast...)
var buf = request[request_line_end + 2 ..];
while (buf.len > 4) {
const index = std.mem.indexOfScalar(u8, buf, '\r') orelse unreachable;
const separator = std.mem.indexOfScalar(u8, buf[0..index], ':') orelse return error.InvalidRequest;
const name = std.mem.trim(u8, toLower(buf[0..separator]), &std.ascii.whitespace);
const value = std.mem.trim(u8, buf[(separator + 1)..index], &std.ascii.whitespace);
if (std.mem.eql(u8, name, "upgrade")) {
if (!std.ascii.eqlIgnoreCase("websocket", value)) {
return error.InvalidUpgradeHeader;
}
required_headers |= 1;
} else if (std.mem.eql(u8, name, "sec-websocket-version")) {
if (value.len != 2 or value[0] != '1' or value[1] != '3') {
return error.InvalidVersionHeader;
}
required_headers |= 2;
} else if (std.mem.eql(u8, name, "connection")) {
// find if connection header has upgrade in it, example header:
// Connection: keep-alive, Upgrade
if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) {
return error.InvalidConnectionHeader;
}
required_headers |= 4;
} else if (std.mem.eql(u8, name, "sec-websocket-key")) {
key = value;
required_headers |= 8;
}
const next = index + 2;
buf = buf[next..];
}
if (required_headers != 15) {
return error.MissingHeaders;
}
// our caller has already made sure this request ended in \r\n\r\n
// so it isn't something we need to check again
const alloc = self.send_arena.allocator();
const response = blk: {
// Response to an upgrade request is always this, with
// the Sec-Websocket-Accept value a spacial sha1 hash of the
// request "sec-websocket-version" and a magic value.
const template =
"HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: 0000000000000000000000000000\r\n\r\n";
// The response will be sent via the IO Loop and thus has to have its
// own lifetime.
const res = try alloc.dupe(u8, template);
// magic response
const key_pos = res.len - 32;
var h: [20]u8 = undefined;
var hasher = std.crypto.hash.Sha1.init(.{});
hasher.update(key);
// websocket spec always used this value
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
hasher.final(&h);
_ = std.base64.standard.Encoder.encode(res[key_pos .. key_pos + 28], h[0..]);
break :blk res;
const read_bytes = self.read() catch |err| {
log.warn(.app, "CDP read", .{ .err = err });
return false;
};
return self.send(response);
}
pub fn sendHttpError(self: *WsConnection, comptime status: u16, comptime body: []const u8) void {
const response = std.fmt.comptimePrint(
"HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}",
.{ status, body.len, body },
);
// we're going to close this connection anyways, swallowing any
// error seems safe
self.send(response) catch {};
}
pub fn getAddress(self: *WsConnection) !std.net.Address {
var address: std.net.Address = undefined;
var socklen: posix.socklen_t = @sizeOf(std.net.Address);
try posix.getpeername(self.socket, &address.any, &socklen);
return address;
}
pub fn sendClose(self: *WsConnection) void {
self.send(&CLOSE_GOING_AWAY) catch {};
}
pub fn shutdown(self: *WsConnection) void {
posix.shutdown(self.socket, .recv) catch {};
}
pub fn setBlocking(self: *WsConnection, blocking: bool) !void {
if (blocking) {
_ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true })));
} else {
_ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags);
if (read_bytes == 0) {
log.info(.app, "CDP disconnect", .{});
return false;
}
const result = self.processHttpRequest() catch return false;
switch (result) {
.more => continue,
.upgraded => return true,
.close => return false,
}
}
};
}
pub fn read(self: *WsConnection) !usize {
const n = try posix.read(self.socket, self.reader.readBuf());
self.reader.len += n;
return n;
}
fn processHttpRequest(self: *WsConnection) !HttpResult {
assert(self.reader.pos == 0, "WsConnection.HTTP pos", .{ .pos = self.reader.pos });
const request = self.reader.buf[0..self.reader.len];
if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) {
self.sendHttpError(413, "Request too large");
return error.RequestTooLarge;
}
// we're only expecting [body-less] GET requests.
if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) {
// we need more data, put any more data here
return .more;
}
// the next incoming data can go to the front of our buffer
defer self.reader.len = 0;
return self.handleHttpRequest(request) catch |err| {
switch (err) {
error.NotFound => self.sendHttpError(404, "Not found"),
error.InvalidRequest => self.sendHttpError(400, "Invalid request"),
error.InvalidProtocol => self.sendHttpError(400, "Invalid HTTP protocol"),
error.MissingHeaders => self.sendHttpError(400, "Missing required header"),
error.InvalidUpgradeHeader => self.sendHttpError(400, "Unsupported upgrade type"),
error.InvalidVersionHeader => self.sendHttpError(400, "Invalid websocket version"),
error.InvalidConnectionHeader => self.sendHttpError(400, "Invalid connection header"),
else => {
log.err(.app, "server 500", .{ .err = err, .req = request[0..@min(100, request.len)] });
self.sendHttpError(500, "Internal Server Error");
},
}
return err;
};
}
fn handleHttpRequest(self: *WsConnection, request: []u8) !HttpResult {
if (request.len < 18) {
// 18 is [generously] the smallest acceptable HTTP request
return error.InvalidRequest;
}
if (std.mem.eql(u8, request[0..4], "GET ") == false) {
return error.NotFound;
}
const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse {
return error.InvalidRequest;
};
const url = request[4..url_end];
if (std.mem.eql(u8, url, "/")) {
try self.upgrade(request);
return .upgraded;
}
if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) {
try self.send(self.json_version_response);
// Chromedp (a Go driver) does an http request to /json/version
// then to / (websocket upgrade) using a different connection.
// Since we only allow 1 connection at a time, the 2nd one (the
// websocket upgrade) blocks until the first one times out.
// We can avoid that by closing the connection. json_version_response
// has a Connection: Close header too.
self.shutdown();
return .close;
}
if (std.mem.eql(u8, url, "/json/list") or std.mem.eql(u8, url, "/json/list/") or
std.mem.eql(u8, url, "/json") or std.mem.eql(u8, url, "/json/"))
{
try self.send(empty_json_list_response);
self.shutdown();
return .close;
}
return error.NotFound;
}
const empty_json_list_response =
"HTTP/1.1 200 OK\r\n" ++
"Content-Length: 2\r\n" ++
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
"[]";
pub fn processMessages(self: *WsConnection, handler: anytype) !bool {
var reader = &self.reader;
while (true) {
const msg = reader.next() catch |err| {
switch (err) {
error.TooLarge => self.send(&CLOSE_TOO_BIG) catch {},
error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.ControlTooLarge => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.InvalidContinuation => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.NestedFragmentation => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.OutOfMemory => {}, // don't borther trying to send an error in this case
}
return err;
} orelse break;
switch (msg.type) {
.pong => {},
.ping => try self.sendPong(msg.data),
.close => {
self.send(&CLOSE_NORMAL) catch {};
return false;
},
.text, .binary => if (handler.handleMessage(msg.data) == false) {
return false;
},
}
if (msg.cleanup_fragment) {
reader.cleanup();
}
}
// We might have read part of the next message. Our reader potentially
// has to move data around in its buffer to make space.
reader.compact();
return true;
}
pub fn upgrade(self: *WsConnection, request: []u8) !void {
// our caller already confirmed that we have a trailing \r\n\r\n
const request_line_end = std.mem.indexOfScalar(u8, request, '\r') orelse unreachable;
const request_line = request[0..request_line_end];
if (!std.ascii.endsWithIgnoreCase(request_line, "http/1.1")) {
return error.InvalidProtocol;
}
// we need to extract the sec-websocket-key value
var key: []const u8 = "";
// we need to make sure that we got all the necessary headers + values
var required_headers: u8 = 0;
// can't std.mem.split because it forces the iterated value to be const
// (we could @constCast...)
var buf = request[request_line_end + 2 ..];
while (buf.len > 4) {
const index = std.mem.indexOfScalar(u8, buf, '\r') orelse unreachable;
const separator = std.mem.indexOfScalar(u8, buf[0..index], ':') orelse return error.InvalidRequest;
const name = std.mem.trim(u8, toLower(buf[0..separator]), &std.ascii.whitespace);
const value = std.mem.trim(u8, buf[(separator + 1)..index], &std.ascii.whitespace);
if (std.mem.eql(u8, name, "upgrade")) {
if (!std.ascii.eqlIgnoreCase("websocket", value)) {
return error.InvalidUpgradeHeader;
}
required_headers |= 1;
} else if (std.mem.eql(u8, name, "sec-websocket-version")) {
if (value.len != 2 or value[0] != '1' or value[1] != '3') {
return error.InvalidVersionHeader;
}
required_headers |= 2;
} else if (std.mem.eql(u8, name, "connection")) {
// find if connection header has upgrade in it, example header:
// Connection: keep-alive, Upgrade
if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) {
return error.InvalidConnectionHeader;
}
required_headers |= 4;
} else if (std.mem.eql(u8, name, "sec-websocket-key")) {
key = value;
required_headers |= 8;
}
const next = index + 2;
buf = buf[next..];
}
if (required_headers != 15) {
return error.MissingHeaders;
}
// our caller has already made sure this request ended in \r\n\r\n
// so it isn't something we need to check again
const alloc = self.send_arena.allocator();
const response = blk: {
// Response to an upgrade request is always this, with
// the Sec-Websocket-Accept value a spacial sha1 hash of the
// request "sec-websocket-version" and a magic value.
const template =
"HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: 0000000000000000000000000000\r\n\r\n";
// The response will be sent via the IO Loop and thus has to have its
// own lifetime.
const res = try alloc.dupe(u8, template);
// magic response
const key_pos = res.len - 32;
var h: [20]u8 = undefined;
var hasher = std.crypto.hash.Sha1.init(.{});
hasher.update(key);
// websocket spec always used this value
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
hasher.final(&h);
_ = std.base64.standard.Encoder.encode(res[key_pos .. key_pos + 28], h[0..]);
break :blk res;
};
return self.send(response);
}
pub fn sendHttpError(self: *WsConnection, comptime status: u16, comptime body: []const u8) void {
const response = std.fmt.comptimePrint(
"HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}",
.{ status, body.len, body },
);
// we're going to close this connection anyways, swallowing any
// error seems safe
self.send(response) catch {};
}
pub fn getAddress(self: *WsConnection) !std.net.Address {
var address: std.net.Address = undefined;
var socklen: posix.socklen_t = @sizeOf(std.net.Address);
try posix.getpeername(self.socket, &address.any, &socklen);
return address;
}
pub fn sendClose(self: *WsConnection) void {
self.send(&CLOSE_GOING_AWAY) catch {};
}
pub fn shutdown(self: *WsConnection) void {
posix.shutdown(self.socket, .recv) catch {};
}
pub fn setBlocking(self: *WsConnection, blocking: bool) !void {
if (blocking) {
_ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true })));
} else {
_ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags);
}
}
fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 {
// can't use buf[0..10] here, because the header length

View File

@@ -32,7 +32,7 @@ const RobotsLayer = @This();
next: Layer = undefined,
allocator: std.mem.Allocator,
pending: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(Request)) = .empty,
pending: std.StringHashMapUnmanaged(std.ArrayList(Request)) = .empty,
pub fn layer(self: *RobotsLayer) Layer {
return .{
@@ -166,7 +166,7 @@ const RobotsContext = struct {
arena: std.mem.Allocator,
client: *Client,
robots_url: [:0]const u8,
buffer: std.ArrayListUnmanaged(u8),
buffer: std.ArrayList(u8),
status: u16 = 0,
fn deinit(self: *RobotsContext) void {

View File

@@ -333,7 +333,6 @@ fn isJsonValue(a: std.json.Value, b: std.json.Value) bool {
}
pub var test_app: *App = undefined;
pub var test_http: *HttpClient = undefined;
pub var test_browser: Browser = undefined;
pub var test_notification: *Notification = undefined;
pub var test_session: *Session = undefined;
@@ -499,10 +498,7 @@ test "tests:beforeAll" {
test_app = try App.init(test_allocator, &test_config);
errdefer test_app.deinit();
test_http = try HttpClient.init(test_allocator, &test_app.network);
errdefer test_http.deinit();
test_browser = try Browser.init(test_app, .{ .http_client = test_http });
try test_browser.init(test_app, .{}, null);
errdefer test_browser.deinit();
// Create notification for testing
@@ -557,7 +553,6 @@ test "tests:afterAll" {
test_notification.deinit();
test_browser.deinit();
test_http.deinit();
test_app.deinit();
test_config.deinit(@import("root").tracking_allocator);
}