mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Merge branch 'main' into agent
This commit is contained in:
2
.github/actions/install/action.yml
vendored
2
.github/actions/install/action.yml
vendored
@@ -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
|
||||
|
||||
79
.github/workflows/package-archlinux.yml
vendored
79
.github/workflows/package-archlinux.yml
vendored
@@ -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
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
17
README.md
17
README.md
@@ -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
|
||||
|
||||
@@ -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 = .{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
523
src/Server.zig
523
src/Server.zig
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
71
src/browser/js/RegExp.zig
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
src/browser/tests/net/fetch-worker.js
Normal file
50
src/browser/tests/net/fetch-worker.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
74
src/browser/tests/net/xhr-worker.js
Normal file
74
src/browser/tests/net/xhr-worker.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
1
src/browser/tests/worker/import-script1.js
Normal file
1
src/browser/tests/worker/import-script1.js
Normal file
@@ -0,0 +1 @@
|
||||
postMessage('importScripts-1');
|
||||
1
src/browser/tests/worker/import-script2.js
Normal file
1
src/browser/tests/worker/import-script2.js
Normal file
@@ -0,0 +1 @@
|
||||
postMessage('importScripts-2');
|
||||
1
src/browser/tests/worker/importScripts-worker.js
Normal file
1
src/browser/tests/worker/importScripts-worker.js
Normal file
@@ -0,0 +1 @@
|
||||
importScripts('import-script1.js', 'import-script2.js');
|
||||
85
src/browser/tests/worker/timers-worker.js
Normal file
85
src/browser/tests/worker/timers-worker.js
Normal 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 });
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
205
src/browser/webapi/Timers.zig
Normal file
205
src/browser/webapi/Timers.zig
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
153
src/cdp/CDP.zig
153
src/cdp/CDP.zig
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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", .{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user