Compare commits

..

37 Commits

Author SHA1 Message Date
Muki Kiboigo
5bb86b77fb add layers to HttpClient 2026-04-01 08:11:52 -07:00
Muki Kiboigo
c02a966776 fix self.req.ctx in HttpClient 2026-04-01 06:04:01 -07:00
Muki Kiboigo
99112d75ff remove cache revalidation stubs 2026-04-01 06:04:01 -07:00
Muki Kiboigo
49b3a039cd add Vary support 2026-04-01 06:04:01 -07:00
Muki Kiboigo
7750125472 add more FsCache tests 2026-04-01 06:04:01 -07:00
Muki Kiboigo
e986878554 always close file on serveFromCache 2026-04-01 06:04:00 -07:00
Muki Kiboigo
f5d36c87cb better logging for cache 2026-04-01 06:04:00 -07:00
Muki Kiboigo
d288a63ebb switch to single file cache 2026-04-01 06:04:00 -07:00
Muki Kiboigo
501c523534 properly deinit cache 2026-04-01 06:04:00 -07:00
Muki Kiboigo
41f9e6521b use wyhash for power of two lock stripes 2026-04-01 06:04:00 -07:00
Muki Kiboigo
4632f241d2 require timestamp passed in with cache request 2026-04-01 06:04:00 -07:00
Muki Kiboigo
7ef024ba05 remove unused cache method on fs cache 2026-04-01 06:04:00 -07:00
Muki Kiboigo
917da08d5d add basic fs cache get/put test 2026-04-01 06:03:59 -07:00
Muki Kiboigo
6a090af250 check age on fs cache get 2026-04-01 06:03:59 -07:00
Muki Kiboigo
92996fe2d5 only store stuff when we know we will cache 2026-04-01 06:03:59 -07:00
Muki Kiboigo
2c7eba9d83 shortcircuit a lot of caching checks 2026-04-01 06:03:59 -07:00
Muki Kiboigo
cfb6eaa412 ensure fs cache file is closed after use 2026-04-01 06:03:59 -07:00
Muki Kiboigo
b26c35aa7e use CacheRequest instead of key 2026-04-01 06:03:59 -07:00
Muki Kiboigo
0a5a9f8012 add striped lock to FsCache 2026-04-01 06:03:59 -07:00
Muki Kiboigo
41cf6efe32 use arena_pool for cache get 2026-04-01 06:03:56 -07:00
Muki Kiboigo
81e23fd020 use writer for fs cache body file 2026-04-01 06:02:34 -07:00
Muki Kiboigo
983b2ab79d use json for fs cache metadata file 2026-04-01 06:02:33 -07:00
Muki Kiboigo
fe7583f931 use sha256 instead of wyhash 2026-04-01 06:02:33 -07:00
Muki Kiboigo
06b3351d97 store type_buf and sub_type_buf in Mime 2026-04-01 06:02:33 -07:00
Muki Kiboigo
43aa267324 add more http caching rules 2026-04-01 06:02:33 -07:00
Muki Kiboigo
69df3c69ae use CacheControl and Vary 2026-04-01 06:02:33 -07:00
Muki Kiboigo
6c81c670d8 cache headers along with response 2026-04-01 06:02:33 -07:00
Muki Kiboigo
b2972a7508 add basic caching support 2026-04-01 06:02:32 -07:00
Muki Kiboigo
a7fa575446 create cache owned by the network struct 2026-04-01 06:02:32 -07:00
Muki Kiboigo
1b6e9df68b use enum approach instead of vtable 2026-04-01 06:02:32 -07:00
Muki Kiboigo
5c545d94d7 add basic FsCache impl 2026-04-01 06:02:32 -07:00
Muki Kiboigo
65307c7a5d add CachedResponse variant to Response 2026-04-01 06:02:32 -07:00
Muki Kiboigo
f12d9b64a6 add headerIterator to Http Response 2026-04-01 06:02:32 -07:00
Muki Kiboigo
f6a27ecbc4 use Response instead of Transfer in callbacks 2026-04-01 06:02:30 -07:00
Muki Kiboigo
489c6e299f allow Mime parse to use []const u8 2026-04-01 06:00:41 -07:00
Muki Kiboigo
fc87bb849c add cache dir to configuration opts 2026-04-01 06:00:40 -07:00
Muki Kiboigo
98532cb5f3 create Cache interface file 2026-04-01 06:00:38 -07:00
33 changed files with 2181 additions and 3146 deletions

View File

@@ -462,7 +462,6 @@ fn buildCurl(
.CURL_DISABLE_SMTP = true, .CURL_DISABLE_SMTP = true,
.CURL_DISABLE_TELNET = true, .CURL_DISABLE_TELNET = true,
.CURL_DISABLE_TFTP = true, .CURL_DISABLE_TFTP = true,
.CURL_DISABLE_WEBSOCKETS = false, // Enable WebSocket support
.ssize_t = null, .ssize_t = null,
._FILE_OFFSET_BITS = 64, ._FILE_OFFSET_BITS = 64,

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2", .minimum_zig_version = "0.15.2",
.dependencies = .{ .dependencies = .{
.v8 = .{ .v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/99c1ddf2d0b15f141e92ea09abdfc8e0e5f441e6.tar.gz", .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
.hash = "v8-0.0.0-xddH63-BBABP05dni8oMrs9qQwuczHhNhXHbXXlPb95s", .hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
}, },
// .v8 = .{ .path = "../zig-v8-fork" }, // .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{ .brotli = .{

View File

@@ -55,7 +55,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
.arena_pool = undefined, .arena_pool = undefined,
}; };
app.network = try Network.init(allocator, config); app.network = try Network.init(allocator, app, config);
errdefer app.network.deinit(); errdefer app.network.deinit();
app.platform = try Platform.init(); app.platform = try Platform.init();

View File

@@ -156,6 +156,13 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
}; };
} }
pub fn cacheDir(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.cache_dir,
else => null,
};
}
pub fn cdpTimeout(self: *const Config) usize { pub fn cdpTimeout(self: *const Config) usize {
return switch (self.mode) { return switch (self.mode) {
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
@@ -267,6 +274,7 @@ pub const Common = struct {
log_format: ?log.Format = null, log_format: ?log.Format = null,
log_filter_scopes: ?[]log.Scope = null, log_filter_scopes: ?[]log.Scope = null,
user_agent_suffix: ?[]const u8 = null, user_agent_suffix: ?[]const u8 = null,
cache_dir: ?[]const u8 = null,
web_bot_auth_key_file: ?[]const u8 = null, web_bot_auth_key_file: ?[]const u8 = null,
web_bot_auth_keyid: ?[]const u8 = null, web_bot_auth_keyid: ?[]const u8 = null,
@@ -1047,5 +1055,14 @@ fn parseCommonArg(
return true; return true;
} }
if (std.mem.eql(u8, "--cache_dir", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--cache_dir" });
return error.InvalidArgument;
};
common.cache_dir = try allocator.dupe(u8, str);
return true;
}
return false; return false;
} }

View File

@@ -260,14 +260,14 @@ pub const Client = struct {
fn start(self: *Client) void { fn start(self: *Client) void {
const http = self.http; const http = self.http;
http.cdp_client = .{ http.setCdpClient(.{
.socket = self.ws.socket, .socket = self.ws.socket,
.ctx = self, .ctx = self,
.blocking_read_start = Client.blockingReadStart, .blocking_read_start = Client.blockingReadStart,
.blocking_read = Client.blockingRead, .blocking_read = Client.blockingRead,
.blocking_read_end = Client.blockingReadStop, .blocking_read_end = Client.blockingReadStop,
}; });
defer http.cdp_client = null; defer http.setCdpClient(null);
self.httpLoop(http) catch |err| { self.httpLoop(http) catch |err| {
log.err(.app, "CDP client loop", .{ .err = err }); log.err(.app, "CDP client loop", .{ .err = err });

View File

@@ -1,371 +0,0 @@
// 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 posix = std.posix;
const TestWSServer = @This();
shutdown: std.atomic.Value(bool),
listener: ?posix.socket_t,
pub fn init() TestWSServer {
return .{
.shutdown = .init(true),
.listener = null,
};
}
pub fn deinit(self: *TestWSServer) void {
if (self.listener) |socket| {
posix.close(socket);
self.listener = null;
}
}
pub fn stop(self: *TestWSServer) void {
self.shutdown.store(true, .release);
if (self.listener) |socket| {
posix.close(socket);
self.listener = null;
}
}
pub fn run(self: *TestWSServer, wg: *std.Thread.WaitGroup) void {
self.runImpl(wg) catch |err| {
std.debug.print("WebSocket echo server error: {}\n", .{err});
};
}
fn runImpl(self: *TestWSServer, wg: *std.Thread.WaitGroup) !void {
const socket = try posix.socket(posix.AF.INET, posix.SOCK.STREAM, 0);
errdefer posix.close(socket);
const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 9584);
try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
try posix.bind(socket, &addr.any, addr.getOsSockLen());
try posix.listen(socket, 8);
self.listener = socket;
self.shutdown.store(false, .release);
wg.finish();
while (!self.shutdown.load(.acquire)) {
var client_addr: posix.sockaddr = undefined;
var addr_len: posix.socklen_t = @sizeOf(posix.sockaddr);
const client = posix.accept(socket, &client_addr, &addr_len, 0) catch |err| {
if (self.shutdown.load(.acquire)) return;
std.debug.print("[WS Server] Accept error: {}\n", .{err});
continue;
};
const thread = std.Thread.spawn(.{}, handleClient, .{client}) catch |err| {
std.debug.print("[WS Server] Thread spawn error: {}\n", .{err});
posix.close(client);
continue;
};
thread.detach();
}
}
fn handleClient(client: posix.socket_t) void {
defer posix.close(client);
var buf: [4096]u8 = undefined;
const n = posix.read(client, &buf) catch return;
const request = buf[0..n];
// Find Sec-WebSocket-Key
const key_header = "Sec-WebSocket-Key: ";
const key_start = std.mem.indexOf(u8, request, key_header) orelse return;
const key_line_start = key_start + key_header.len;
const key_end = std.mem.indexOfScalarPos(u8, request, key_line_start, '\r') orelse return;
const key = request[key_line_start..key_end];
// Compute accept key
var hasher = std.crypto.hash.Sha1.init(.{});
hasher.update(key);
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
var hash: [20]u8 = undefined;
hasher.final(&hash);
var accept_key: [28]u8 = undefined;
_ = std.base64.standard.Encoder.encode(&accept_key, &hash);
// Send upgrade response
var resp_buf: [256]u8 = undefined;
const resp = std.fmt.bufPrint(&resp_buf, "HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: Upgrade\r\n" ++
"Sec-WebSocket-Accept: {s}\r\n\r\n", .{accept_key}) catch return;
_ = posix.write(client, resp) catch return;
// Message loop with larger buffer for big messages
var msg_buf: [128 * 1024]u8 = undefined;
var recv_buf = RecvBuffer{ .buf = &msg_buf };
while (true) {
const frame = recv_buf.readFrame(client) orelse break;
// Close frame - echo it back before closing
if (frame.opcode == 8) {
sendFrame(client, 8, "", frame.payload) catch {};
break;
}
// Handle commands or echo
if (frame.opcode == 1) { // Text
handleTextMessage(client, frame.payload) catch break;
} else if (frame.opcode == 2) { // Binary
handleBinaryMessage(client, frame.payload) catch break;
}
}
}
const Frame = struct {
opcode: u8,
payload: []u8,
};
const RecvBuffer = struct {
buf: []u8,
start: usize = 0,
end: usize = 0,
fn available(self: *RecvBuffer) []u8 {
return self.buf[self.start..self.end];
}
fn consume(self: *RecvBuffer, n: usize) void {
self.start += n;
if (self.start >= self.end) {
self.start = 0;
self.end = 0;
}
}
fn ensureBytes(self: *RecvBuffer, client: posix.socket_t, needed: usize) bool {
while (self.end - self.start < needed) {
// Compact buffer if needed
if (self.end >= self.buf.len - 1024) {
const avail = self.end - self.start;
std.mem.copyForwards(u8, self.buf[0..avail], self.buf[self.start..self.end]);
self.start = 0;
self.end = avail;
}
const n = posix.read(client, self.buf[self.end..]) catch return false;
if (n == 0) return false;
self.end += n;
}
return true;
}
fn readFrame(self: *RecvBuffer, client: posix.socket_t) ?Frame {
// Need at least 2 bytes for basic header
if (!self.ensureBytes(client, 2)) return null;
const data = self.available();
const opcode = data[0] & 0x0F;
const masked = (data[1] & 0x80) != 0;
var payload_len: usize = data[1] & 0x7F;
var header_size: usize = 2;
// Extended payload length
if (payload_len == 126) {
if (!self.ensureBytes(client, 4)) return null;
const d = self.available();
payload_len = @as(usize, d[2]) << 8 | d[3];
header_size = 4;
} else if (payload_len == 127) {
if (!self.ensureBytes(client, 10)) return null;
const d = self.available();
payload_len = @as(usize, d[2]) << 56 |
@as(usize, d[3]) << 48 |
@as(usize, d[4]) << 40 |
@as(usize, d[5]) << 32 |
@as(usize, d[6]) << 24 |
@as(usize, d[7]) << 16 |
@as(usize, d[8]) << 8 |
d[9];
header_size = 10;
}
const mask_size: usize = if (masked) 4 else 0;
const total_frame_size = header_size + mask_size + payload_len;
if (!self.ensureBytes(client, total_frame_size)) return null;
const frame_data = self.available();
// Get mask key if present
var mask_key: [4]u8 = undefined;
if (masked) {
@memcpy(&mask_key, frame_data[header_size..][0..4]);
}
// Get payload and unmask
const payload_start = header_size + mask_size;
const payload = frame_data[payload_start..][0..payload_len];
if (masked) {
for (payload, 0..) |*b, i| {
b.* ^= mask_key[i % 4];
}
}
self.consume(total_frame_size);
return .{ .opcode = opcode, .payload = payload };
}
};
fn handleTextMessage(client: posix.socket_t, payload: []const u8) !void {
// Command: force-close - close socket immediately without close frame
if (std.mem.eql(u8, payload, "force-close")) {
return error.ForceClose;
}
// Command: send-large:N - send a message of N bytes
if (std.mem.startsWith(u8, payload, "send-large:")) {
const size_str = payload["send-large:".len..];
const size = std.fmt.parseInt(usize, size_str, 10) catch return error.InvalidCommand;
try sendLargeMessage(client, size);
return;
}
// Command: close:CODE:REASON - send close frame with specific code/reason
if (std.mem.startsWith(u8, payload, "close:")) {
const rest = payload["close:".len..];
if (std.mem.indexOf(u8, rest, ":")) |sep| {
const code = std.fmt.parseInt(u16, rest[0..sep], 10) catch 1000;
const reason = rest[sep + 1 ..];
try sendCloseFrame(client, code, reason);
}
return;
}
// Default: echo with "echo-" prefix
const prefix = "echo-";
try sendFrame(client, 1, prefix, payload);
}
fn handleBinaryMessage(client: posix.socket_t, payload: []const u8) !void {
// Echo binary data back with byte 0xEE prepended as marker
const marker = [_]u8{0xEE};
try sendFrame(client, 2, &marker, payload);
}
fn sendFrame(client: posix.socket_t, opcode: u8, prefix: []const u8, payload: []const u8) !void {
const total_len = prefix.len + payload.len;
// Build header
var header: [10]u8 = undefined;
var header_len: usize = 2;
header[0] = 0x80 | opcode; // FIN + opcode
if (total_len <= 125) {
header[1] = @intCast(total_len);
} else if (total_len <= 65535) {
header[1] = 126;
header[2] = @intCast((total_len >> 8) & 0xFF);
header[3] = @intCast(total_len & 0xFF);
header_len = 4;
} else {
header[1] = 127;
header[2] = @intCast((total_len >> 56) & 0xFF);
header[3] = @intCast((total_len >> 48) & 0xFF);
header[4] = @intCast((total_len >> 40) & 0xFF);
header[5] = @intCast((total_len >> 32) & 0xFF);
header[6] = @intCast((total_len >> 24) & 0xFF);
header[7] = @intCast((total_len >> 16) & 0xFF);
header[8] = @intCast((total_len >> 8) & 0xFF);
header[9] = @intCast(total_len & 0xFF);
header_len = 10;
}
_ = try posix.write(client, header[0..header_len]);
if (prefix.len > 0) {
_ = try posix.write(client, prefix);
}
if (payload.len > 0) {
_ = try posix.write(client, payload);
}
}
fn sendLargeMessage(client: posix.socket_t, size: usize) !void {
// Build header
var header: [10]u8 = undefined;
var header_len: usize = 2;
header[0] = 0x81; // FIN + text
if (size <= 125) {
header[1] = @intCast(size);
} else if (size <= 65535) {
header[1] = 126;
header[2] = @intCast((size >> 8) & 0xFF);
header[3] = @intCast(size & 0xFF);
header_len = 4;
} else {
header[1] = 127;
header[2] = @intCast((size >> 56) & 0xFF);
header[3] = @intCast((size >> 48) & 0xFF);
header[4] = @intCast((size >> 40) & 0xFF);
header[5] = @intCast((size >> 32) & 0xFF);
header[6] = @intCast((size >> 24) & 0xFF);
header[7] = @intCast((size >> 16) & 0xFF);
header[8] = @intCast((size >> 8) & 0xFF);
header[9] = @intCast(size & 0xFF);
header_len = 10;
}
_ = try posix.write(client, header[0..header_len]);
// Send payload in chunks - pattern of 'A'-'Z' repeating
var sent: usize = 0;
var chunk: [4096]u8 = undefined;
while (sent < size) {
const to_send = @min(chunk.len, size - sent);
for (chunk[0..to_send], 0..) |*b, i| {
b.* = @intCast('A' + ((sent + i) % 26));
}
_ = try posix.write(client, chunk[0..to_send]);
sent += to_send;
}
}
fn sendCloseFrame(client: posix.socket_t, code: u16, reason: []const u8) !void {
const reason_len = @min(reason.len, 123); // Max 123 bytes for reason
const payload_len = 2 + reason_len;
var frame: [129]u8 = undefined; // 2 header + 2 code + 123 reason + 2 padding
frame[0] = 0x88; // FIN + close
frame[1] = @intCast(payload_len);
frame[2] = @intCast((code >> 8) & 0xFF);
frame[3] = @intCast(code & 0xFF);
if (reason_len > 0) {
@memcpy(frame[4..][0..reason_len], reason[0..reason_len]);
}
_ = try posix.write(client, frame[0 .. 4 + reason_len]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,9 @@ charset: [41]u8 = default_charset,
charset_len: usize = default_charset_len, charset_len: usize = default_charset_len,
is_default_charset: bool = true, is_default_charset: bool = true,
type_buf: [127]u8 = @splat(0),
sub_type_buf: [127]u8 = @splat(0),
/// String "UTF-8" continued by null characters. /// String "UTF-8" continued by null characters.
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
const default_charset_len = 5; const default_charset_len = 5;
@@ -61,7 +64,10 @@ pub const ContentType = union(ContentTypeEnum) {
image_webp: void, image_webp: void,
application_json: void, application_json: void,
unknown: void, unknown: void,
other: struct { type: []const u8, sub_type: []const u8 }, other: struct {
type: []const u8,
sub_type: []const u8,
},
}; };
pub fn contentTypeString(mime: *const Mime) []const u8 { pub fn contentTypeString(mime: *const Mime) []const u8 {
@@ -112,17 +118,18 @@ fn parseCharset(value: []const u8) error{ CharsetTooBig, Invalid }![]const u8 {
return value; return value;
} }
pub fn parse(input: []u8) !Mime { pub fn parse(input: []const u8) !Mime {
if (input.len > 255) { if (input.len > 255) {
return error.TooBig; return error.TooBig;
} }
// Zig's trim API is broken. The return type is always `[]const u8`, var buf: [255]u8 = undefined;
// even if the input type is `[]u8`. @constCast is safe here. const normalized = std.ascii.lowerString(&buf, std.mem.trim(u8, input, &std.ascii.whitespace));
var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace));
_ = std.ascii.lowerString(normalized, normalized); _ = std.ascii.lowerString(normalized, normalized);
const content_type, const type_len = try parseContentType(normalized); var mime = Mime{ .content_type = undefined };
const content_type, const type_len = try parseContentType(normalized, &mime.type_buf, &mime.sub_type_buf);
if (type_len >= normalized.len) { if (type_len >= normalized.len) {
return .{ .content_type = content_type }; return .{ .content_type = content_type };
} }
@@ -163,13 +170,12 @@ pub fn parse(input: []u8) !Mime {
} }
} }
return .{ mime.params = params;
.params = params, mime.charset = charset;
.charset = charset, mime.charset_len = charset_len;
.charset_len = charset_len, mime.content_type = content_type;
.content_type = content_type, mime.is_default_charset = !has_explicit_charset;
.is_default_charset = !has_explicit_charset, return mime;
};
} }
/// Prescan the first 1024 bytes of an HTML document for a charset declaration. /// Prescan the first 1024 bytes of an HTML document for a charset declaration.
@@ -395,7 +401,7 @@ pub fn isText(mime: *const Mime) bool {
} }
// we expect value to be lowercase // we expect value to be lowercase
fn parseContentType(value: []const u8) !struct { ContentType, usize } { fn parseContentType(value: []const u8, type_buf: []u8, sub_type_buf: []u8) !struct { ContentType, usize } {
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len; const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
const type_name = trimRight(value[0..end]); const type_name = trimRight(value[0..end]);
const attribute_start = end + 1; const attribute_start = end + 1;
@@ -444,10 +450,18 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
return error.Invalid; return error.Invalid;
} }
return .{ .{ .other = .{ @memcpy(type_buf[0..main_type.len], main_type);
.type = main_type, @memcpy(sub_type_buf[0..sub_type.len], sub_type);
.sub_type = sub_type,
} }, attribute_start }; return .{
.{
.other = .{
.type = type_buf[0..main_type.len],
.sub_type = sub_type_buf[0..sub_type.len],
},
},
attribute_start,
};
} }
const VALID_CODEPOINTS = blk: { const VALID_CODEPOINTS = blk: {
@@ -461,6 +475,13 @@ const VALID_CODEPOINTS = blk: {
break :blk v; break :blk v;
}; };
pub fn typeString(self: *const Mime) []const u8 {
return switch (self.content_type) {
.other => |o| o.type[0..o.type_len],
else => "",
};
}
fn validType(value: []const u8) bool { fn validType(value: []const u8) bool {
for (value) |b| { for (value) |b| {
if (VALID_CODEPOINTS[b] == false) { if (VALID_CODEPOINTS[b] == false) {

View File

@@ -854,12 +854,10 @@ fn notifyParentLoadComplete(self: *Page) void {
parent.iframeCompletedLoading(self.iframe.?); parent.iframeCompletedLoading(self.iframe.?);
} }
fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { fn pageHeaderDoneCallback(response: HttpClient.Response) !bool {
var self: *Page = @ptrCast(@alignCast(transfer.ctx)); var self: *Page = @ptrCast(@alignCast(response.ctx));
const header = &transfer.response_header.?; const response_url = response.url();
const response_url = std.mem.span(header.url);
if (std.mem.eql(u8, response_url, self.url) == false) { if (std.mem.eql(u8, response_url, self.url) == false) {
// would be different than self.url in the case of a redirect // would be different than self.url in the case of a redirect
self.url = try self.arena.dupeZ(u8, response_url); self.url = try self.arena.dupeZ(u8, response_url);
@@ -873,8 +871,8 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.page, "navigate header", .{ log.debug(.page, "navigate header", .{
.url = self.url, .url = self.url,
.status = header.status, .status = response.status(),
.content_type = header.contentType(), .content_type = response.contentType(),
.type = self._type, .type = self._type,
}); });
} }
@@ -895,14 +893,14 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
return true; return true;
} }
fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn pageDataCallback(response: HttpClient.Response, data: []const u8) !void {
var self: *Page = @ptrCast(@alignCast(transfer.ctx)); var self: *Page = @ptrCast(@alignCast(response.ctx));
if (self._parse_state == .pre) { if (self._parse_state == .pre) {
// we lazily do this, because we might need the first chunk of data // we lazily do this, because we might need the first chunk of data
// to sniff the content type // to sniff the content type
var mime: Mime = blk: { var mime: Mime = blk: {
if (transfer.response_header.?.contentType()) |ct| { if (response.contentType()) |ct| {
break :blk try Mime.parse(ct); break :blk try Mime.parse(ct);
} }
break :blk Mime.sniff(data); break :blk Mime.sniff(data);

View File

@@ -137,7 +137,7 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
.pre, .raw, .text, .image => { .pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating. // The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler. // There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and (comptime is_cdp) == false) { if (http_client.active() == 0 and (comptime is_cdp) == false) {
// haven't started navigating, I guess. // haven't started navigating, I guess.
return .done; return .done;
} }
@@ -171,8 +171,8 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
// Each call to this runs scheduled load events. // Each call to this runs scheduled load events.
try page.dispatchLoad(); try page.dispatchLoad();
const http_active = http_client.active; const http_active = http_client.active();
const total_network_activity = http_active + http_client.intercepted; const total_network_activity = http_active + http_client.intercepted();
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
page.notifyNetworkAlmostIdle(); page.notifyNetworkAlmostIdle();
} }
@@ -185,7 +185,7 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
// because is_cdp is true, and that can only be // because is_cdp is true, and that can only be
// the case when interception isn't possible. // the case when interception isn't possible.
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0); std.debug.assert(http_client.intercepted() == 0);
} }
if (browser.hasBackgroundTasks()) { if (browser.hasBackgroundTasks()) {

View File

@@ -694,82 +694,86 @@ pub const Script = struct {
self.manager.page.releaseArena(self.arena); self.manager.page.releaseArena(self.arena);
} }
fn startCallback(transfer: *HttpClient.Transfer) !void { fn startCallback(response: HttpClient.Response) !void {
log.debug(.http, "script fetch start", .{ .req = transfer }); log.debug(.http, "script fetch start", .{ .req = response });
} }
fn headerCallback(transfer: *HttpClient.Transfer) !bool { fn headerCallback(response: HttpClient.Response) !bool {
const self: *Script = @ptrCast(@alignCast(transfer.ctx)); const self: *Script = @ptrCast(@alignCast(response.ctx));
const header = &transfer.response_header.?;
self.status = header.status; self.status = response.status().?;
if (header.status != 200) { if (response.status() != 200) {
log.info(.http, "script header", .{ log.info(.http, "script header", .{
.req = transfer, .req = response,
.status = header.status, .status = response.status(),
.content_type = header.contentType(), .content_type = response.contentType(),
}); });
return false; return false;
} }
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "script header", .{ log.debug(.http, "script header", .{
.req = transfer, .req = response,
.status = header.status, .status = response.status(),
.content_type = header.contentType(), .content_type = response.contentType(),
}); });
} }
{ switch (response.inner) {
// temp debug, trying to figure out why the next assert sometimes .transfer => |transfer| {
// fails. Is the buffer just corrupt or is headerCallback really // temp debug, trying to figure out why the next assert sometimes
// being called twice? // fails. Is the buffer just corrupt or is headerCallback really
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{ // being called twice?
.m = @tagName(std.meta.activeTag(self.mode)), lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
.a1 = self.debug_transfer_id, .m = @tagName(std.meta.activeTag(self.mode)),
.a2 = self.debug_transfer_tries, .a1 = self.debug_transfer_id,
.a3 = self.debug_transfer_aborted, .a2 = self.debug_transfer_tries,
.a4 = self.debug_transfer_bytes_received, .a3 = self.debug_transfer_aborted,
.a5 = self.debug_transfer_notified_fail, .a4 = self.debug_transfer_bytes_received,
.a7 = self.debug_transfer_intercept_state, .a5 = self.debug_transfer_notified_fail,
.a8 = self.debug_transfer_auth_challenge, .a7 = self.debug_transfer_intercept_state,
.a9 = self.debug_transfer_easy_id, .a8 = self.debug_transfer_auth_challenge,
.b1 = transfer.id, .a9 = self.debug_transfer_easy_id,
.b2 = transfer._tries, .b1 = transfer.id,
.b3 = transfer.aborted, .b2 = transfer._tries,
.b4 = transfer.bytes_received, .b3 = transfer.aborted,
.b5 = transfer._notified_fail, .b4 = transfer.bytes_received,
.b7 = @intFromEnum(transfer._intercept_state), .b5 = transfer._notified_fail,
.b8 = transfer._auth_challenge != null, .b7 = @intFromEnum(transfer._intercept_state),
.b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0, .b8 = transfer._auth_challenge != null,
}); .b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
self.header_callback_called = true; });
self.debug_transfer_id = transfer.id; self.header_callback_called = true;
self.debug_transfer_tries = transfer._tries; self.debug_transfer_id = transfer.id;
self.debug_transfer_aborted = transfer.aborted; self.debug_transfer_tries = transfer._tries;
self.debug_transfer_bytes_received = transfer.bytes_received; self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_notified_fail = transfer._notified_fail; self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state); self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_auth_challenge = transfer._auth_challenge != null; self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0; self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
},
else => {},
} }
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity }); lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
var buffer: std.ArrayList(u8) = .empty; var buffer: std.ArrayList(u8) = .empty;
if (transfer.getContentLength()) |cl| { if (response.contentLength()) |cl| {
try buffer.ensureTotalCapacity(self.arena, cl); try buffer.ensureTotalCapacity(self.arena, cl);
} }
self.source = .{ .remote = buffer }; self.source = .{ .remote = buffer };
return true; return true;
} }
fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn dataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(transfer.ctx)); const self: *Script = @ptrCast(@alignCast(response.ctx));
self._dataCallback(transfer, data) catch |err| { self._dataCallback(response, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len }); log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = response, .len = data.len });
return err; return err;
}; };
} }
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
fn _dataCallback(self: *Script, _: HttpClient.Response, data: []const u8) !void {
try self.source.remote.appendSlice(self.arena, data); try self.source.remote.appendSlice(self.arena, data);
} }

View File

@@ -829,8 +829,6 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/net/URLSearchParams.zig"), @import("../webapi/net/URLSearchParams.zig"),
@import("../webapi/net/XMLHttpRequest.zig"), @import("../webapi/net/XMLHttpRequest.zig"),
@import("../webapi/net/XMLHttpRequestEventTarget.zig"), @import("../webapi/net/XMLHttpRequestEventTarget.zig"),
@import("../webapi/net/WebSocket.zig"),
@import("../webapi/event/CloseEvent.zig"),
@import("../webapi/streams/ReadableStream.zig"), @import("../webapi/streams/ReadableStream.zig"),
@import("../webapi/streams/ReadableStreamDefaultReader.zig"), @import("../webapi/streams/ReadableStreamDefaultReader.zig"),
@import("../webapi/streams/ReadableStreamDefaultController.zig"), @import("../webapi/streams/ReadableStreamDefaultController.zig"),

View File

@@ -1,240 +0,0 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=basic_echo type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('msg1');
});
ws.addEventListener('close', (e) => {
received.push(['close', e.code, e.reason]);
state.resolve();
});
ws.addEventListener('message', (e) => {
received.push(e.data);
ws.close(1000, 'bye');
});
await state.done(() => {
testing.expectEqual([
'echo-msg1',
['close', 1000, 'bye'],
], received);
});
}
</script>
<script id=multiple_messages type=module>
{
const state = await testing.async();
let received = [];
let sendCount = 0;
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('first');
ws.send('second');
ws.send('third');
sendCount = 3;
});
ws.addEventListener('message', (e) => {
received.push(e.data);
if (received.length === sendCount) {
ws.close();
}
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([
'echo-first',
'echo-second',
'echo-third',
], received);
});
}
</script>
<script id=empty_message type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('');
});
ws.addEventListener('message', (e) => {
received.push(e.data);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
// Empty message echoed with "echo-" prefix
testing.expectEqual(['echo-'], received);
});
}
</script>
<script id=boundary_125 type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
// 120 bytes + "echo-" prefix = 125 bytes response (single-byte length)
ws.send('A'.repeat(120));
});
ws.addEventListener('message', (e) => {
received.push(e.data.length);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([125], received);
});
}
</script>
<script id=boundary_16bit type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
// 200 bytes message
ws.send('B'.repeat(200));
});
ws.addEventListener('message', (e) => {
received.push(e.data.length);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([205], received); // 200 + "echo-".length
});
}
</script>
<script id=receive_large type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
// Ask server to send us a 10000 byte message
ws.send('send-large:10000');
});
ws.addEventListener('message', (e) => {
received.push(e.data.length);
// Verify pattern (A-Z repeating)
const expected_start = 'ABCDEFGHIJ';
received.push(e.data.substring(0, 10) === expected_start);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([10000, true], received);
});
}
</script>
<script id=send_large type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
// Send 5000 byte message
ws.send('X'.repeat(5000));
});
ws.addEventListener('message', (e) => {
received.push(e.data.length);
// Check it starts with echo- and then our Xs
received.push(e.data.startsWith('echo-XXXXX'));
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([5005, true], received); // 5000 + "echo-".length
});
}
</script>
<script id=binary_uint8array type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
ws.send(data);
});
ws.addEventListener('message', (e) => {
const arr = new Uint8Array(e.data);
// Server prepends 0xEE marker to binary responses
received.push(arr[0]); // 0xEE marker
received.push(arr.length);
received.push(arr[1]); // Our first byte
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([0xEE, 6, 1], received);
});
}
</script>

View File

@@ -1,233 +0,0 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=binary_arraybuffer type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
const buffer = new ArrayBuffer(4);
const view = new Uint8Array(buffer);
view[0] = 10;
view[1] = 20;
view[2] = 30;
view[3] = 40;
ws.send(buffer);
});
ws.addEventListener('message', (e) => {
const arr = new Uint8Array(e.data);
received.push(arr.length);
received.push(arr[1]); // First byte of our data (after 0xEE marker)
received.push(arr[4]); // Last byte of our data
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([5, 10, 40], received);
});
}
</script>
<script id=binary_int32array type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
const arr = new Int32Array([0x01020304, 0x05060708]);
ws.send(arr);
});
ws.addEventListener('message', (e) => {
received.push(e.data.byteLength);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
// 1 marker byte + 8 bytes (2 x 4-byte int32)
testing.expectEqual([9], received);
});
}
</script>
<script id=binary_int32array22 type=module>
{
const state = await testing.async();
let received = [];
console.warn('last-test');
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
const arr = new Int32Array([0x01020304, 0x05060708]);
ws.send(arr);
});
ws.addEventListener('message', (e) => {
received.push(e.data.byteLength);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
console.warn('aaa');
// 1 marker byte + 8 bytes (2 x 4-byte int32)
testing.expectEqual([9], received);
});
}
</script>
<script id=binary_blob type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
const blob = new Blob(['hello'], { type: 'text/plain' });
ws.send(blob);
});
ws.addEventListener('message', (e) => {
const arr = new Uint8Array(e.data);
received.push(arr.length);
received.push(arr[0]); // 0xEE marker
// 'h' = 104, 'e' = 101, 'l' = 108
received.push(arr[1]); // 'h'
received.push(arr[2]); // 'e'
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([6, 0xEE, 104, 101], received);
});
}
</script>
<script id=server_close type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('close:1001:going away');
});
ws.addEventListener('close', (e) => {
received.push(e.code);
received.push(e.reason);
received.push(e.wasClean);
state.resolve();
});
await state.done(() => {
testing.expectEqual([1001, 'going away', true], received);
});
}
</script>
<script id=force_close type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('force-close');
});
ws.addEventListener('close', (e) => {
received.push('closed');
received.push(e.wasClean);
state.resolve();
});
ws.addEventListener('error', () => {
received.push('error');
});
await state.done(() => {
// Connection was not cleanly closed
testing.expectEqual(['closed', false], received);
});
}
</script>
<script id=ready_state type=module>
{
const state = await testing.async();
let states = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
states.push(ws.readyState); // CONNECTING = 0
ws.addEventListener('open', () => {
states.push(ws.readyState); // OPEN = 1
ws.close();
states.push(ws.readyState); // CLOSING = 2
});
ws.addEventListener('close', () => {
states.push(ws.readyState); // CLOSED = 3
state.resolve();
});
await state.done(() => {
testing.expectEqual([0, 1, 2, 3], states);
});
}
</script>
<script id=buffered_amount type=module>
{
const state = await testing.async();
let results = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
results.push(ws.bufferedAmount); // Should be 0 initially
ws.send('test');
// bufferedAmount might be non-zero right after send
// but will go to 0 after message is sent
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([0], results);
});
}
</script>

View File

@@ -1,77 +0,0 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=handler_properties type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.onopen = () => {
ws.send('handler-test');
};
ws.onmessage = (e) => {
received.push(e.data);
ws.close();
};
ws.onclose = () => {
received.push('closed');
state.resolve();
};
await state.done(() => {
testing.expectEqual(['echo-handler-test', 'closed'], received);
});
}
</script>
<script id=binary_type type=module>
{
const state = await testing.async();
let results = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
results.push(ws.binaryType); // Default is 'blob'
ws.binaryType = 'arraybuffer';
results.push(ws.binaryType);
ws.binaryType = 'blob';
results.push(ws.binaryType);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual(['blob', 'arraybuffer', 'blob'], results);
});
}
</script>
<script id=url_property type=module>
{
const state = await testing.async();
let result = null;
let ws = new WebSocket('ws://127.0.0.1:9584/path');
ws.addEventListener('open', () => {
result = ws.url;
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual('ws://127.0.0.1:9584/path', result);
});
}
</script>

View File

@@ -80,7 +80,6 @@ pub const Type = union(enum) {
promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"), promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"),
submit_event: *@import("event/SubmitEvent.zig"), submit_event: *@import("event/SubmitEvent.zig"),
form_data_event: *@import("event/FormDataEvent.zig"), form_data_event: *@import("event/FormDataEvent.zig"),
close_event: *@import("event/CloseEvent.zig"),
}; };
pub const Options = struct { pub const Options = struct {
@@ -172,7 +171,6 @@ pub fn is(self: *Event, comptime T: type) ?*T {
.promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null, .promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null,
.submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null, .submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null,
.form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null, .form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null,
.close_event => |e| return if (T == @import("event/CloseEvent.zig")) e else null,
.ui_event => |e| { .ui_event => |e| {
if (T == @import("event/UIEvent.zig")) { if (T == @import("event/UIEvent.zig")) {
return e; return e;

View File

@@ -45,7 +45,6 @@ pub const Type = union(enum) {
visual_viewport: *@import("VisualViewport.zig"), visual_viewport: *@import("VisualViewport.zig"),
file_reader: *@import("FileReader.zig"), file_reader: *@import("FileReader.zig"),
font_face_set: *@import("css/FontFaceSet.zig"), font_face_set: *@import("css/FontFaceSet.zig"),
websocket: *@import("net/WebSocket.zig"),
}; };
pub fn init(page: *Page) !*EventTarget { pub fn init(page: *Page) !*EventTarget {
@@ -142,7 +141,6 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.visual_viewport => writer.writeAll("<VisualViewport>"), .visual_viewport => writer.writeAll("<VisualViewport>"),
.file_reader => writer.writeAll("<FileReader>"), .file_reader => writer.writeAll("<FileReader>"),
.font_face_set => writer.writeAll("<FontFaceSet>"), .font_face_set => writer.writeAll("<FontFaceSet>"),
.websocket => writer.writeAll("<WebSocket>"),
}; };
} }
@@ -162,7 +160,6 @@ pub fn toString(self: *EventTarget) []const u8 {
.visual_viewport => return "[object VisualViewport]", .visual_viewport => return "[object VisualViewport]",
.file_reader => return "[object FileReader]", .file_reader => return "[object FileReader]",
.font_face_set => return "[object FontFaceSet]", .font_face_set => return "[object FontFaceSet]",
.websocket => return "[object WebSocket]",
}; };
} }

View File

@@ -125,7 +125,7 @@ const PostMessageCallback = struct {
const target = self.port.asEventTarget(); const target = self.port.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "message", self.port._on_message)) { if (page._event_manager.hasDirectListeners(target, "message", self.port._on_message)) {
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{ const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = .{ .value = self.message }, .data = self.message,
.origin = "", .origin = "",
.source = null, .source = null,
}, page) catch |err| { }, page) catch |err| {

View File

@@ -791,7 +791,7 @@ const PostMessageCallback = struct {
const event_target = window.asEventTarget(); const event_target = window.asEventTarget();
if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) { if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) {
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = .{ .value = self.message }, .data = self.message,
.origin = self.origin, .origin = self.origin,
.source = self.source, .source = self.source,
.bubbles = false, .bubbles = false,

View File

@@ -1,102 +0,0 @@
// 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 String = @import("../../../string.zig").String;
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const Event = @import("../Event.zig");
const Allocator = std.mem.Allocator;
const CloseEvent = @This();
_proto: *Event,
_code: u16 = 1000,
_reason: []const u8 = "",
_was_clean: bool = true,
const CloseEventOptions = struct {
code: u16 = 1000,
reason: []const u8 = "",
wasClean: bool = true,
};
const Options = Event.inheritOptions(CloseEvent, CloseEventOptions);
pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*CloseEvent {
const arena = try page.getArena(.{ .debug = "CloseEvent" });
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, _opts, false, page);
}
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*CloseEvent {
const arena = try page.getArena(.{ .debug = "CloseEvent.trusted" });
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, page);
}
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*CloseEvent {
const opts = _opts orelse Options{};
const event = try page._factory.event(
arena,
typ,
CloseEvent{
._proto = undefined,
._code = opts.code,
._reason = if (opts.reason.len > 0) try arena.dupe(u8, opts.reason) else "",
._was_clean = opts.wasClean,
},
);
Event.populatePrototypes(event, opts, trusted);
return event;
}
pub fn asEvent(self: *CloseEvent) *Event {
return self._proto;
}
pub fn getCode(self: *const CloseEvent) u16 {
return self._code;
}
pub fn getReason(self: *const CloseEvent) []const u8 {
return self._reason;
}
pub fn getWasClean(self: *const CloseEvent) bool {
return self._was_clean;
}
pub const JsApi = struct {
const js = @import("../../js/js.zig");
pub const bridge = js.Bridge(CloseEvent);
pub const Meta = struct {
pub const name = "CloseEvent";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(CloseEvent.init, .{});
pub const code = bridge.accessor(CloseEvent.getCode, null, .{});
pub const reason = bridge.accessor(CloseEvent.getReason, null, .{});
pub const wasClean = bridge.accessor(CloseEvent.getWasClean, null, .{});
};

View File

@@ -30,22 +30,16 @@ const Allocator = std.mem.Allocator;
const MessageEvent = @This(); const MessageEvent = @This();
_proto: *Event, _proto: *Event,
_data: ?Data = null, _data: ?js.Value.Temp = null,
_origin: []const u8 = "", _origin: []const u8 = "",
_source: ?*Window = null, _source: ?*Window = null,
const MessageEventOptions = struct { const MessageEventOptions = struct {
data: ?Data = null, data: ?js.Value.Temp = null,
origin: ?[]const u8 = null, origin: ?[]const u8 = null,
source: ?*Window = null, source: ?*Window = null,
}; };
pub const Data = union(enum) {
value: js.Value.Temp,
string: []const u8,
arraybuffer: js.ArrayBuffer,
};
const Options = Event.inheritOptions(MessageEvent, MessageEventOptions); const Options = Event.inheritOptions(MessageEvent, MessageEventOptions);
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent { pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent {
@@ -81,10 +75,7 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool
pub fn deinit(self: *MessageEvent, session: *Session) void { pub fn deinit(self: *MessageEvent, session: *Session) void {
if (self._data) |d| { if (self._data) |d| {
switch (d) { d.release();
.value => |js_val| js_val.release(),
.string, .arraybuffer => {},
}
} }
self._proto.deinit(session); self._proto.deinit(session);
} }
@@ -101,7 +92,7 @@ pub fn asEvent(self: *MessageEvent) *Event {
return self._proto; return self._proto;
} }
pub fn getData(self: *const MessageEvent) ?Data { pub fn getData(self: *const MessageEvent) ?js.Value.Temp {
return self._data; return self._data;
} }

View File

@@ -127,16 +127,16 @@ fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, page: *Page) !js
return resolver.promise(); return resolver.promise();
} }
fn httpStartCallback(transfer: *HttpClient.Transfer) !void { fn httpStartCallback(response: HttpClient.Response) !void {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(response.ctx));
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "request start", .{ .url = self._url, .source = "fetch" }); log.debug(.http, "request start", .{ .url = self._url, .source = "fetch" });
} }
self._response._transfer = transfer; self._response._http_response = response;
} }
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { fn httpHeaderDoneCallback(response: HttpClient.Response) !bool {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(response.ctx));
if (self._signal) |signal| { if (self._signal) |signal| {
if (signal._aborted) { if (signal._aborted) {
@@ -145,25 +145,24 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
} }
const arena = self._response._arena; const arena = self._response._arena;
if (transfer.getContentLength()) |cl| { if (response.contentLength()) |cl| {
try self._buf.ensureTotalCapacity(arena, cl); try self._buf.ensureTotalCapacity(arena, cl);
} }
const res = self._response; const res = self._response;
const header = transfer.response_header.?;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "request header", .{ log.debug(.http, "request header", .{
.source = "fetch", .source = "fetch",
.url = self._url, .url = self._url,
.status = header.status, .status = response.status(),
}); });
} }
res._status = header.status; res._status = response.status().?;
res._status_text = std.http.Status.phrase(@enumFromInt(header.status)) orelse ""; res._status_text = std.http.Status.phrase(@enumFromInt(response.status().?)) orelse "";
res._url = try arena.dupeZ(u8, std.mem.span(header.url)); res._url = try arena.dupeZ(u8, response.url());
res._is_redirected = header.redirect_count > 0; res._is_redirected = response.redirectCount().? > 0;
// Determine response type based on origin comparison // Determine response type based on origin comparison
const page_origin = URL.getOrigin(arena, self._page.url) catch null; const page_origin = URL.getOrigin(arena, self._page.url) catch null;
@@ -183,7 +182,7 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
res._type = .basic; res._type = .basic;
} }
var it = transfer.responseHeaderIterator(); var it = response.headerIterator();
while (it.next()) |hdr| { while (it.next()) |hdr| {
try res._headers.append(hdr.name, hdr.value, self._page); try res._headers.append(hdr.name, hdr.value, self._page);
} }
@@ -191,8 +190,8 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
return true; return true;
} }
fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn httpDataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(response.ctx));
// Check if aborted // Check if aborted
if (self._signal) |signal| { if (self._signal) |signal| {
@@ -207,7 +206,7 @@ fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
fn httpDoneCallback(ctx: *anyopaque) !void { fn httpDoneCallback(ctx: *anyopaque) !void {
const self: *Fetch = @ptrCast(@alignCast(ctx)); const self: *Fetch = @ptrCast(@alignCast(ctx));
var response = self._response; var response = self._response;
response._transfer = null; response._http_response = null;
response._body = self._buf.items; response._body = self._buf.items;
log.info(.http, "request complete", .{ log.info(.http, "request complete", .{
@@ -230,7 +229,7 @@ fn httpErrorCallback(ctx: *anyopaque, _: anyerror) void {
const self: *Fetch = @ptrCast(@alignCast(ctx)); const self: *Fetch = @ptrCast(@alignCast(ctx));
var response = self._response; var response = self._response;
response._transfer = null; response._http_response = null;
// the response is only passed on v8 on success, if we're here, it's safe to // the response is only passed on v8 on success, if we're here, it's safe to
// clear this. (defer since `self is in the response's arena). // clear this. (defer since `self is in the response's arena).
@@ -256,7 +255,7 @@ fn httpShutdownCallback(ctx: *anyopaque) void {
if (self._owns_response) { if (self._owns_response) {
var response = self._response; var response = self._response;
response._transfer = null; response._http_response = null;
response.deinit(self._page._session); response.deinit(self._page._session);
// Do not access `self` after this point: the Fetch struct was // Do not access `self` after this point: the Fetch struct was
// allocated from response._arena which has been released. // allocated from response._arena which has been released.

View File

@@ -48,7 +48,7 @@ _type: Type,
_status_text: []const u8, _status_text: []const u8,
_url: [:0]const u8, _url: [:0]const u8,
_is_redirected: bool, _is_redirected: bool,
_transfer: ?*HttpClient.Transfer = null, _http_response: ?HttpClient.Response = null,
const InitOpts = struct { const InitOpts = struct {
status: u16 = 200, status: u16 = 200,
@@ -81,9 +81,9 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response {
} }
pub fn deinit(self: *Response, session: *Session) void { pub fn deinit(self: *Response, session: *Session) void {
if (self._transfer) |transfer| { if (self._http_response) |resp| {
transfer.abort(error.Abort); resp.abort(error.Abort);
self._transfer = null; self._http_response = null;
} }
session.releaseArena(self._arena); session.releaseArena(self._arena);
} }
@@ -191,7 +191,7 @@ pub fn clone(self: *const Response, page: *Page) !*Response {
._type = self._type, ._type = self._type,
._is_redirected = self._is_redirected, ._is_redirected = self._is_redirected,
._headers = try Headers.init(.{ .obj = self._headers }, page), ._headers = try Headers.init(.{ .obj = self._headers }, page),
._transfer = null, ._http_response = null,
}; };
return cloned; return cloned;
} }

View File

@@ -1,687 +0,0 @@
// 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 lp = @import("lightpanda");
const log = @import("../../../log.zig");
const http = @import("../../../network/http.zig");
const js = @import("../../js/js.zig");
const Blob = @import("../Blob.zig");
const URL = @import("../../URL.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const HttpClient = @import("../../HttpClient.zig");
const Event = @import("../Event.zig");
const EventTarget = @import("../EventTarget.zig");
const MessageEvent = @import("../event/MessageEvent.zig");
const CloseEvent = @import("../event/CloseEvent.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const WebSocket = @This();
_rc: lp.RC(u8) = .{},
_page: *Page,
_proto: *EventTarget,
_arena: Allocator,
// Connection state
_ready_state: ReadyState = .connecting,
_url: [:0]const u8 = "",
_binary_type: BinaryType = .blob,
// Handshake tracking
_got_101: bool = false,
_got_upgrade: bool = false,
_conn: ?*http.Connection,
_http_client: *HttpClient,
// buffered outgoing messages
_send_queue: std.ArrayList(Message) = .empty,
_send_offset: usize = 0,
// buffered incoming frame
_recv_buffer: std.ArrayList(u8) = .empty,
// close info for event dispatch
_close_code: u16 = 1000,
_close_reason: []const u8 = "",
// Event handlers
_on_open: ?js.Function.Temp = null,
_on_message: ?js.Function.Temp = null,
_on_error: ?js.Function.Temp = null,
_on_close: ?js.Function.Temp = null,
pub const ReadyState = enum(u8) {
connecting = 0,
open = 1,
closing = 2,
closed = 3,
};
pub const BinaryType = enum {
blob,
arraybuffer,
};
pub fn init(url: []const u8, protocols_: ?[]const u8, page: *Page) !*WebSocket {
if (protocols_) |protocols| {
if (protocols.len > 0) {
log.warn(.not_implemented, "WS protocols", .{ .protocols = protocols });
}
}
{
if (url.len < 6) {
return error.SyntaxError;
}
const normalized_start = std.ascii.lowerString(&page.buf, url[0..6]);
if (!std.mem.startsWith(u8, normalized_start, "ws://") and !std.mem.startsWith(u8, normalized_start, "wss://")) {
return error.SyntaxError;
}
}
const arena = try page.getArena(.{ .debug = "WebSocket" });
errdefer page.releaseArena(arena);
const resolved_url = try URL.resolve(arena, page.base(), url, .{ .always_dupe = true, .encode = true });
const http_client = page._session.browser.http_client;
const conn = http_client.network.getConnection() orelse {
// TODO: figure out how/where we actually want to get WebSocket connections
// from. I feel like sharing this with the HTTP Connection Pool is a
// mistake.
return error.NoFreeConnection;
};
errdefer http_client.network.releaseConnection(conn);
try conn.setURL(resolved_url);
try conn.setConnectOnly(false);
try conn.setReadCallback(sendDataCallback, true);
try conn.setWriteCallback(receivedDataCallback);
try conn.setHeaderCallback(receivedHeaderCalllback);
const self = try page._factory.eventTargetWithAllocator(arena, WebSocket{
._page = page,
._conn = conn,
._arena = arena,
._proto = undefined,
._url = resolved_url,
._http_client = http_client,
});
conn.transport = .{ .websocket = self };
try http_client.trackConn(conn);
if (comptime IS_DEBUG) {
log.info(.http, "WS connecting", .{ .url = url });
}
// Unlike an XHR object where we only selectively reference the instance
// while the request is actually inflight, WS connection is "inflight" from
// the moment it's created.
self.acquireRef();
return self;
}
pub fn deinit(self: *WebSocket, session: *Session) void {
self.cleanup();
if (self._on_open) |func| {
func.release();
}
if (self._on_message) |func| {
func.release();
}
if (self._on_error) |func| {
func.release();
}
if (self._on_close) |func| {
func.release();
}
for (self._send_queue.items) |msg| {
msg.deinit(session);
}
session.releaseArena(self._arena);
}
// we're being aborted internally (e.g. page shutting down)
pub fn kill(self: *WebSocket) void {
self.cleanup();
}
pub fn disconnected(self: *WebSocket, err_: ?anyerror) void {
const was_clean = self._ready_state == .closing and err_ == null;
self._ready_state = .closed;
if (err_) |err| {
log.warn(.http, "WS disconnected", .{ .err = err, .url = self._url });
} else {
log.info(.http, "WS disconnected", .{ .url = self._url, .reason = "closed" });
}
self.cleanup();
// Use 1006 (abnormal closure) if connection wasn't cleanly closed
const code = if (was_clean) self._close_code else 1006;
const reason = if (was_clean) self._close_reason else "";
self.dispatchCloseEvent(code, reason, was_clean) catch |err| {
log.err(.http, "WS close event dispatch failed", .{ .err = err });
};
}
fn cleanup(self: *WebSocket) void {
if (self._conn) |conn| {
self._http_client.removeConn(conn);
self._conn = null;
self.releaseRef(self._page._session);
}
}
pub fn releaseRef(self: *WebSocket, session: *Session) void {
self._rc.release(self, session);
}
pub fn acquireRef(self: *WebSocket) void {
self._rc.acquire();
}
fn asEventTarget(self: *WebSocket) *EventTarget {
return self._proto;
}
fn queueMessage(self: *WebSocket, msg: Message) !void {
const was_empty = self._send_queue.items.len == 0;
try self._send_queue.append(self._arena, msg);
if (was_empty) {
// Unpause the send callback so libcurl will request data
if (self._conn) |conn| {
try conn.pause(.{ .cont = true });
}
}
}
/// WebSocket send() accepts string, Blob, ArrayBuffer, or TypedArray
const SendData = union(enum) {
blob: *Blob,
js_val: js.Value,
};
/// Union for extracting bytes from ArrayBuffer/TypedArray
const BinaryData = union(enum) {
int8: []i8,
uint8: []u8,
int16: []i16,
uint16: []u16,
int32: []i32,
uint32: []u32,
int64: []i64,
uint64: []u64,
fn asBuffer(self: BinaryData) []u8 {
return switch (self) {
.int8 => |b| @as([*]u8, @ptrCast(b.ptr))[0..b.len],
.uint8 => |b| b,
.int16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2],
.uint16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2],
.int32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4],
.uint32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4],
.int64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8],
.uint64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8],
};
}
};
pub fn send(self: *WebSocket, data: SendData) !void {
if (self._ready_state != .open) {
return error.InvalidStateError;
}
// Get a dedicated arena for this message
const arena = try self._page._session.getArena(.{ .debug = "WebSocket message" });
errdefer self._page._session.releaseArena(arena);
switch (data) {
.blob => |blob| {
try self.queueMessage(.{ .binary = .{
.arena = arena,
.data = try arena.dupe(u8, blob._slice),
} });
},
.js_val => |js_val| {
if (js_val.isString()) |str| {
try self.queueMessage(.{ .text = .{
.arena = arena,
.data = try str.toSliceWithAlloc(arena),
} });
} else {
const binary = try js_val.toZig(BinaryData);
try self.queueMessage(.{ .binary = .{
.arena = arena,
.data = try arena.dupe(u8, binary.asBuffer()),
} });
}
},
}
}
pub fn close(self: *WebSocket, code_: ?u16, reason_: ?[]const u8) !void {
if (self._ready_state == .closing or self._ready_state == .closed) {
return;
}
const code = code_ orelse 1000;
const reason = reason_ orelse "";
if (self._ready_state == .connecting) {
// Connection not yet established - fail it
self._ready_state = .closed;
self.cleanup();
try self.dispatchCloseEvent(code, reason, false);
return;
}
self._ready_state = .closing;
self._close_code = code;
self._close_reason = try self._arena.dupe(u8, reason);
try self.queueMessage(.close);
}
pub fn getUrl(self: *const WebSocket) []const u8 {
return self._url;
}
pub fn getReadyState(self: *const WebSocket) u16 {
return @intFromEnum(self._ready_state);
}
pub fn getBufferedAmount(self: *const WebSocket) u32 {
var buffered: u32 = 0;
for (self._send_queue.items) |msg| {
switch (msg) {
.text, .binary => |byte_msg| buffered += @intCast(byte_msg.data.len),
.close => buffered += @intCast(2 + self._close_reason.len),
}
}
return buffered;
}
pub fn getProtocol(self: *const WebSocket) []const u8 {
return self._protocol;
}
pub fn getExtensions(self: *const WebSocket) []const u8 {
return self._extensions;
}
pub fn getBinaryType(self: *const WebSocket) []const u8 {
return @tagName(self._binary_type);
}
pub fn setBinaryType(self: *WebSocket, value: []const u8) void {
if (std.meta.stringToEnum(BinaryType, value)) |bt| {
self._binary_type = bt;
}
}
pub fn getOnOpen(self: *const WebSocket) ?js.Function.Temp {
return self._on_open;
}
pub fn setOnOpen(self: *WebSocket, cb_: ?js.Function) !void {
if (self._on_open) |old| old.release();
if (cb_) |cb| {
self._on_open = try cb.tempWithThis(self);
} else {
self._on_open = null;
}
}
pub fn getOnMessage(self: *const WebSocket) ?js.Function.Temp {
return self._on_message;
}
pub fn setOnMessage(self: *WebSocket, cb_: ?js.Function) !void {
if (self._on_message) |old| old.release();
if (cb_) |cb| {
self._on_message = try cb.tempWithThis(self);
} else {
self._on_message = null;
}
}
pub fn getOnError(self: *const WebSocket) ?js.Function.Temp {
return self._on_error;
}
pub fn setOnError(self: *WebSocket, cb_: ?js.Function) !void {
if (self._on_error) |old| old.release();
if (cb_) |cb| {
self._on_error = try cb.tempWithThis(self);
} else {
self._on_error = null;
}
}
pub fn getOnClose(self: *const WebSocket) ?js.Function.Temp {
return self._on_close;
}
pub fn setOnClose(self: *WebSocket, cb_: ?js.Function) !void {
if (self._on_close) |old| old.release();
if (cb_) |cb| {
self._on_close = try cb.tempWithThis(self);
} else {
self._on_close = null;
}
}
fn dispatchOpenEvent(self: *WebSocket) !void {
const page = self._page;
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "open", self._on_open)) {
const event = try Event.initTrusted(.wrap("open"), .{}, page);
try page._event_manager.dispatchDirect(target, event, self._on_open, .{ .context = "WebSocket open" });
}
}
fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsFrameType) !void {
const page = self._page;
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "message", self._on_message)) {
const msg_data: MessageEvent.Data = if (frame_type == .binary and self._binary_type == .arraybuffer)
.{ .arraybuffer = .{ .values = data } }
else
.{ .string = data };
const event = try MessageEvent.initTrusted(.wrap("message"), .{
.data = msg_data,
.origin = "",
}, page);
try page._event_manager.dispatchDirect(target, event.asEvent(), self._on_message, .{ .context = "WebSocket message" });
}
}
fn dispatchCloseEvent(self: *WebSocket, code: u16, reason: []const u8, was_clean: bool) !void {
const page = self._page;
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "close", self._on_close)) {
const event = try CloseEvent.initTrusted(.wrap("close"), .{
.code = code,
.reason = reason,
.wasClean = was_clean,
}, page);
try page._event_manager.dispatchDirect(target, event.asEvent(), self._on_close, .{ .context = "WebSocket close" });
}
}
fn sendDataCallback(buffer: [*]u8, buf_count: usize, buf_len: usize, data: *anyopaque) usize {
if (comptime IS_DEBUG) {
std.debug.assert(buf_count == 1);
}
const conn: *http.Connection = @ptrCast(@alignCast(data));
return _sendDataCallback(conn, buffer[0..buf_len]) catch |err| {
log.warn(.http, "WS send callback", .{ .err = err });
return http.readfunc_pause;
};
}
fn _sendDataCallback(conn: *http.Connection, buf: []u8) !usize {
lp.assert(buf.len >= 2, "WS short buffer", .{ .len = buf.len });
const self = conn.transport.websocket;
if (self._send_queue.items.len == 0) {
// No data to send - pause until queueMessage is called
return http.readfunc_pause;
}
const msg = &self._send_queue.items[0];
switch (msg.*) {
.close => {
const code = self._close_code;
const reason = self._close_reason;
// Close frame: 2 bytes for code (big-endian) + optional reason
// Truncate reason to fit in buf (max 123 bytes per spec)
const reason_len: usize = @min(reason.len, 123, buf.len -| 2);
const frame_len = 2 + reason_len;
const to_copy = @min(buf.len, frame_len);
var close_payload: [125]u8 = undefined;
close_payload[0] = @intCast((code >> 8) & 0xFF);
close_payload[1] = @intCast(code & 0xFF);
if (reason_len > 0) {
@memcpy(close_payload[2..][0..reason_len], reason[0..reason_len]);
}
try conn.wsStartFrame(.close, to_copy);
@memcpy(buf[0..to_copy], close_payload[0..to_copy]);
_ = self._send_queue.orderedRemove(0);
return to_copy;
},
.text => |content| return self.writeContent(conn, buf, content, .text),
.binary => |content| return self.writeContent(conn, buf, content, .binary),
}
}
fn writeContent(self: *WebSocket, conn: *http.Connection, buf: []u8, byte_msg: Message.Content, frame_type: http.WsFrameType) !usize {
if (self._send_offset == 0) {
// start of the message
try conn.wsStartFrame(frame_type, byte_msg.data.len);
}
const remaining = byte_msg.data[self._send_offset..];
const to_copy = @min(remaining.len, buf.len);
@memcpy(buf[0..to_copy], remaining[0..to_copy]);
self._send_offset += to_copy;
if (self._send_offset >= byte_msg.data.len) {
const removed = self._send_queue.orderedRemove(0);
removed.deinit(self._page._session);
self._send_offset = 0;
}
return to_copy;
}
fn receivedDataCallback(buffer: [*]const u8, buf_count: usize, buf_len: usize, data: *anyopaque) usize {
if (comptime IS_DEBUG) {
std.debug.assert(buf_count == 1);
}
const conn: *http.Connection = @ptrCast(@alignCast(data));
_receivedDataCallback(conn, buffer[0..buf_len]) catch |err| {
log.warn(.http, "WS receive callback", .{ .err = err });
// TODO: are there errors, like an invalid frame, that we shouldn't treat
// as an error?
return http.writefunc_error;
};
return buf_len;
}
fn _receivedDataCallback(conn: *http.Connection, data: []const u8) !void {
const self = conn.transport.websocket;
const meta = conn.wsMeta() orelse {
log.err(.http, "WS missing meta", .{ .url = self._url });
return error.NoFrameMeta;
};
if (meta.offset == 0) {
// Start of new frame. Pre-allocate buffer
self._recv_buffer.clearRetainingCapacity();
if (meta.len > self._http_client.max_response_size) {
return error.MessageTooLarge;
}
try self._recv_buffer.ensureTotalCapacity(self._arena, meta.len);
}
try self._recv_buffer.appendSlice(self._arena, data);
if (meta.bytes_left > 0) {
// still more data waiting for this frame
return;
}
const message = self._recv_buffer.items;
switch (meta.frame_type) {
.text, .binary => try self.dispatchMessageEvent(message, meta.frame_type),
.close => {
// Parse close frame: 2-byte code (big-endian) + optional reason
self._close_code = if (message.len >= 2)
@as(u16, message[0]) << 8 | message[1]
else
1005; // No status code received
if (message.len > 2) {
self._close_reason = try self._arena.dupe(u8, message[2..]);
}
self._ready_state = .closing;
self.disconnected(null);
},
.ping, .pong, .cont => {},
}
}
// libcurl has no mechanism to signal that the connection is established. The
// best option I could come up with was looking for an upgrade header response.
fn receivedHeaderCalllback(buffer: [*]const u8, header_count: usize, buf_len: usize, data: *anyopaque) usize {
if (comptime IS_DEBUG) {
std.debug.assert(header_count == 1);
}
const conn: *http.Connection = @ptrCast(@alignCast(data));
const self = conn.transport.websocket;
const header = buffer[0..buf_len];
if (self._got_101 == false and std.mem.startsWith(u8, header, "HTTP/")) {
if (std.mem.indexOf(u8, header, " 101 ")) |_| {
self._got_101 = true;
}
return buf_len;
}
// Empty line = end of headers
if (buf_len <= 2) {
if (!self._got_101 or !self._got_upgrade) {
return 0;
}
self._ready_state = .open;
log.info(.http, "WS connected", .{ .url = self._url });
self.dispatchOpenEvent() catch |err| {
log.err(.http, "WS open event fail", .{ .err = err });
};
return buf_len;
}
if (self._got_upgrade) {
// dont' care about headers once we've gotten the upgrade header
return buf_len;
}
const colon = std.mem.indexOfScalarPos(u8, header, 0, ':') orelse {
// weird, continue...
return buf_len;
};
if (std.ascii.eqlIgnoreCase(header[0..colon], "upgrade") == false) {
return buf_len;
}
const value = std.mem.trim(u8, header[colon + 1 ..], " \t\r\n");
if (std.ascii.eqlIgnoreCase(value, "websocket")) {
self._got_upgrade = true;
}
return buf_len;
}
const Message = union(enum) {
close,
text: Content,
binary: Content,
const Content = struct {
arena: Allocator,
data: []const u8,
};
fn deinit(self: Message, session: *Session) void {
switch (self) {
.text, .binary => |msg| session.releaseArena(msg.arena),
.close => {},
}
}
};
pub const JsApi = struct {
pub const bridge = js.Bridge(WebSocket);
pub const Meta = struct {
pub const name = "WebSocket";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(WebSocket.init, .{ .dom_exception = true });
pub const CONNECTING = bridge.property(@intFromEnum(ReadyState.connecting), .{ .template = true });
pub const OPEN = bridge.property(@intFromEnum(ReadyState.open), .{ .template = true });
pub const CLOSING = bridge.property(@intFromEnum(ReadyState.closing), .{ .template = true });
pub const CLOSED = bridge.property(@intFromEnum(ReadyState.closed), .{ .template = true });
pub const url = bridge.accessor(WebSocket.getUrl, null, .{});
pub const readyState = bridge.accessor(WebSocket.getReadyState, null, .{});
pub const bufferedAmount = bridge.accessor(WebSocket.getBufferedAmount, null, .{});
pub const binaryType = bridge.accessor(WebSocket.getBinaryType, WebSocket.setBinaryType, .{});
pub const protocol = bridge.property("", .{ .template = false });
pub const extensions = bridge.property("", .{ .template = false });
pub const onopen = bridge.accessor(WebSocket.getOnOpen, WebSocket.setOnOpen, .{});
pub const onmessage = bridge.accessor(WebSocket.getOnMessage, WebSocket.setOnMessage, .{});
pub const onerror = bridge.accessor(WebSocket.getOnError, WebSocket.setOnError, .{});
pub const onclose = bridge.accessor(WebSocket.getOnClose, WebSocket.setOnClose, .{});
pub const send = bridge.function(WebSocket.send, .{ .dom_exception = true });
pub const close = bridge.function(WebSocket.close, .{});
};
const testing = @import("../../../testing.zig");
test "WebApi: WebSocket" {
// TEMP since we're currently limited to 10 concurrent connections
try testing.htmlRunner("net/websocket.html", .{});
try testing.htmlRunner("net/websocket2.html", .{});
try testing.htmlRunner("net/websocket3.html", .{});
}

View File

@@ -43,7 +43,7 @@ _rc: lp.RC(u8) = .{},
_page: *Page, _page: *Page,
_proto: *XMLHttpRequestEventTarget, _proto: *XMLHttpRequestEventTarget,
_arena: Allocator, _arena: Allocator,
_transfer: ?*HttpClient.Transfer = null, _http_response: ?HttpClient.Response = null,
_active_request: bool = false, _active_request: bool = false,
_url: [:0]const u8 = "", _url: [:0]const u8 = "",
@@ -100,9 +100,9 @@ pub fn init(page: *Page) !*XMLHttpRequest {
} }
pub fn deinit(self: *XMLHttpRequest, session: *Session) void { pub fn deinit(self: *XMLHttpRequest, session: *Session) void {
if (self._transfer) |transfer| { if (self._http_response) |resp| {
transfer.abort(error.Abort); resp.abort(error.Abort);
self._transfer = null; self._http_response = null;
} }
if (self._on_ready_state_change) |func| { if (self._on_ready_state_change) |func| {
@@ -184,9 +184,9 @@ pub fn setWithCredentials(self: *XMLHttpRequest, value: bool) !void {
// TODO: url should be a union, as it can be multiple things // TODO: url should be a union, as it can be multiple things
pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void { pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void {
// Abort any in-progress request // Abort any in-progress request
if (self._transfer) |transfer| { if (self._http_response) |transfer| {
transfer.abort(error.Abort); transfer.abort(error.Abort);
self._transfer = null; self._http_response = null;
} }
// Reset internal state // Reset internal state
@@ -398,34 +398,32 @@ pub fn getResponseXML(self: *XMLHttpRequest, page: *Page) !?*Node.Document {
}; };
} }
fn httpStartCallback(transfer: *HttpClient.Transfer) !void { fn httpStartCallback(response: HttpClient.Response) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "request start", .{ .method = self._method, .url = self._url, .source = "xhr" }); log.debug(.http, "request start", .{ .method = self._method, .url = self._url, .source = "xhr" });
} }
self._transfer = transfer; self._http_response = response;
} }
fn httpHeaderCallback(transfer: *HttpClient.Transfer, header: http.Header) !void { fn httpHeaderCallback(response: HttpClient.Response, header: http.Header) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value }); const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value });
try self._response_headers.append(self._arena, joined); try self._response_headers.append(self._arena, joined);
} }
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { fn httpHeaderDoneCallback(response: HttpClient.Response) !bool {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
const header = &transfer.response_header.?;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "request header", .{ log.debug(.http, "request header", .{
.source = "xhr", .source = "xhr",
.url = self._url, .url = self._url,
.status = header.status, .status = response.status(),
}); });
} }
if (header.contentType()) |ct| { if (response.contentType()) |ct| {
self._response_mime = Mime.parse(ct) catch |e| { self._response_mime = Mime.parse(ct) catch |e| {
log.info(.http, "invalid content type", .{ log.info(.http, "invalid content type", .{
.content_Type = ct, .content_Type = ct,
@@ -436,18 +434,18 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
}; };
} }
var it = transfer.responseHeaderIterator(); var it = response.headerIterator();
while (it.next()) |hdr| { while (it.next()) |hdr| {
const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value }); const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value });
try self._response_headers.append(self._arena, joined); try self._response_headers.append(self._arena, joined);
} }
self._response_status = header.status; self._response_status = response.status().?;
if (transfer.getContentLength()) |cl| { if (response.contentLength()) |cl| {
self._response_len = cl; self._response_len = cl;
try self._response_data.ensureTotalCapacity(self._arena, cl); try self._response_data.ensureTotalCapacity(self._arena, cl);
} }
self._response_url = try self._arena.dupeZ(u8, std.mem.span(header.url)); self._response_url = try self._arena.dupeZ(u8, response.url());
const page = self._page; const page = self._page;
@@ -462,8 +460,8 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
return true; return true;
} }
fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn httpDataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
try self._response_data.appendSlice(self._arena, data); try self._response_data.appendSlice(self._arena, data);
const page = self._page; const page = self._page;
@@ -486,7 +484,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
// Not that the request is done, the http/client will free the transfer // Not that the request is done, the http/client will free the transfer
// object. It isn't safe to keep it around. // object. It isn't safe to keep it around.
self._transfer = null; self._http_response = null;
const page = self._page; const page = self._page;
@@ -509,22 +507,22 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx));
// http client will close it after an error, it isn't safe to keep around // http client will close it after an error, it isn't safe to keep around
self.handleError(err); self.handleError(err);
if (self._transfer != null) { if (self._http_response != null) {
self._transfer = null; self._http_response = null;
} }
self.releaseSelfRef(); self.releaseSelfRef();
} }
fn httpShutdownCallback(ctx: *anyopaque) void { fn httpShutdownCallback(ctx: *anyopaque) void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx));
self._transfer = null; self._http_response = null;
} }
pub fn abort(self: *XMLHttpRequest) void { pub fn abort(self: *XMLHttpRequest) void {
self.handleError(error.Abort); self.handleError(error.Abort);
if (self._transfer) |transfer| { if (self._http_response) |resp| {
self._transfer = null; self._http_response = null;
transfer.abort(error.Abort); resp.abort(error.Abort);
} }
self.releaseSelfRef(); self.releaseSelfRef();
} }

View File

@@ -139,8 +139,8 @@ fn setLifecycleEventsEnabled(cmd: *CDP.Command) !void {
try sendPageLifecycle(bc, "load", now, frame_id, loader_id); try sendPageLifecycle(bc, "load", now, frame_id, loader_id);
const http_client = page._session.browser.http_client; const http_client = page._session.browser.http_client;
const http_active = http_client.active; const http_active = http_client.active();
const total_network_activity = http_active + http_client.intercepted; const total_network_activity = http_active + http_client.intercepted();
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
try sendPageLifecycle(bc, "networkAlmostIdle", now, frame_id, loader_id); try sendPageLifecycle(bc, "networkAlmostIdle", now, frame_id, loader_id);
} }

View File

@@ -39,6 +39,7 @@ pub const Scope = enum {
telemetry, telemetry,
unknown_prop, unknown_prop,
mcp, mcp,
cache,
}; };
const Opts = struct { const Opts = struct {

View File

@@ -29,7 +29,9 @@ const libcurl = @import("../sys/libcurl.zig");
const http = @import("http.zig"); const http = @import("http.zig");
const RobotStore = @import("Robots.zig").RobotStore; const RobotStore = @import("Robots.zig").RobotStore;
const WebBotAuth = @import("WebBotAuth.zig"); const WebBotAuth = @import("WebBotAuth.zig");
const Cache = @import("cache/Cache.zig");
const App = @import("../App.zig");
const Network = @This(); const Network = @This();
const Listener = struct { const Listener = struct {
@@ -45,10 +47,12 @@ const MAX_TICK_CALLBACKS = 16;
allocator: Allocator, allocator: Allocator,
app: *App,
config: *const Config, config: *const Config,
ca_blob: ?http.Blob, ca_blob: ?http.Blob,
robot_store: RobotStore, robot_store: RobotStore,
web_bot_auth: ?WebBotAuth, web_bot_auth: ?WebBotAuth,
cache: ?Cache,
connections: []http.Connection, connections: []http.Connection,
available: std.DoublyLinkedList = .{}, available: std.DoublyLinkedList = .{},
@@ -200,7 +204,7 @@ fn globalDeinit() void {
libcurl.curl_global_cleanup(); libcurl.curl_global_cleanup();
} }
pub fn init(allocator: Allocator, config: *const Config) !Network { pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network {
globalInit(allocator); globalInit(allocator);
errdefer globalDeinit(); errdefer globalDeinit();
@@ -233,6 +237,11 @@ pub fn init(allocator: Allocator, config: *const Config) !Network {
else else
null; null;
const cache = if (config.cacheDir()) |cache_dir_path|
Cache{ .kind = .{ .fs = try .init(cache_dir_path) } }
else
null;
return .{ return .{
.allocator = allocator, .allocator = allocator,
.config = config, .config = config,
@@ -244,8 +253,10 @@ pub fn init(allocator: Allocator, config: *const Config) !Network {
.available = available, .available = available,
.connections = connections, .connections = connections,
.app = app,
.robot_store = RobotStore.init(allocator), .robot_store = RobotStore.init(allocator),
.web_bot_auth = web_bot_auth, .web_bot_auth = web_bot_auth,
.cache = cache,
}; };
} }
@@ -278,6 +289,8 @@ pub fn deinit(self: *Network) void {
wba.deinit(self.allocator); wba.deinit(self.allocator);
} }
if (self.cache) |*cache| cache.deinit();
globalDeinit(); globalDeinit();
} }

156
src/network/cache/Cache.zig vendored Normal file
View File

@@ -0,0 +1,156 @@
// 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 Http = @import("../http.zig");
const FsCache = @import("FsCache.zig");
/// A browser-wide cache for resources across the network.
/// This mostly conforms to RFC9111 with regards to caching behavior.
pub const Cache = @This();
kind: union(enum) {
fs: FsCache,
},
pub fn deinit(self: *Cache) void {
return switch (self.kind) {
inline else => |*c| c.deinit(),
};
}
pub fn get(self: *Cache, arena: std.mem.Allocator, req: CacheRequest) ?CachedResponse {
return switch (self.kind) {
inline else => |*c| c.get(arena, req),
};
}
pub fn put(self: *Cache, metadata: CachedMetadata, body: []const u8) !void {
return switch (self.kind) {
inline else => |*c| c.put(metadata, body),
};
}
pub const CacheControl = struct {
max_age: u64,
pub fn parse(value: []const u8) ?CacheControl {
var cc: CacheControl = .{ .max_age = undefined };
var max_age_set = false;
var max_s_age_set = false;
var is_public = false;
var iter = std.mem.splitScalar(u8, value, ',');
while (iter.next()) |part| {
const directive = std.mem.trim(u8, part, &std.ascii.whitespace);
if (std.ascii.eqlIgnoreCase(directive, "no-store")) {
return null;
} else if (std.ascii.eqlIgnoreCase(directive, "no-cache")) {
return null;
} else if (std.ascii.eqlIgnoreCase(directive, "public")) {
is_public = true;
} else if (std.ascii.startsWithIgnoreCase(directive, "max-age=")) {
if (!max_s_age_set) {
if (std.fmt.parseInt(u64, directive[8..], 10) catch null) |max_age| {
cc.max_age = max_age;
max_age_set = true;
}
}
} else if (std.ascii.startsWithIgnoreCase(directive, "s-maxage=")) {
if (std.fmt.parseInt(u64, directive[9..], 10) catch null) |max_age| {
cc.max_age = max_age;
max_age_set = true;
max_s_age_set = true;
}
}
}
if (!max_age_set) return null;
if (!is_public) return null;
if (cc.max_age == 0) return null;
return cc;
}
};
pub const CachedMetadata = struct {
url: [:0]const u8,
content_type: []const u8,
status: u16,
stored_at: i64,
age_at_store: u64,
cache_control: CacheControl,
/// Response Headers
headers: []const Http.Header,
/// These are Request Headers used by Vary.
vary_headers: []const Http.Header,
};
pub const CacheRequest = struct {
url: []const u8,
timestamp: i64,
request_headers: []const Http.Header,
};
pub const CachedData = union(enum) {
buffer: []const u8,
file: struct {
file: std.fs.File,
offset: usize,
len: usize,
},
};
pub const CachedResponse = struct {
metadata: CachedMetadata,
data: CachedData,
};
pub fn tryCache(
arena: std.mem.Allocator,
timestamp: i64,
url: [:0]const u8,
status: u16,
content_type: ?[]const u8,
cache_control: ?[]const u8,
vary: ?[]const u8,
age: ?[]const u8,
has_set_cookie: bool,
has_authorization: bool,
) !?CachedMetadata {
if (status != 200) return null;
if (has_set_cookie) return null;
if (has_authorization) return null;
if (vary) |v| if (std.mem.eql(u8, v, "*")) return null;
const cc = CacheControl.parse(cache_control orelse return null) orelse return null;
return .{
.url = url,
.content_type = if (content_type) |ct| try arena.dupe(u8, ct) else "application/octet-stream",
.status = status,
.stored_at = timestamp,
.age_at_store = if (age) |a| std.fmt.parseInt(u64, a, 10) catch 0 else 0,
.cache_control = cc,
.headers = &.{},
.vary_headers = &.{},
};
}

580
src/network/cache/FsCache.zig vendored Normal file
View File

@@ -0,0 +1,580 @@
// 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 log = @import("../../log.zig");
const Cache = @import("Cache.zig");
const Http = @import("../http.zig");
const CacheRequest = Cache.CacheRequest;
const CachedMetadata = Cache.CachedMetadata;
const CachedResponse = Cache.CachedResponse;
const CACHE_VERSION: usize = 1;
const LOCK_STRIPES = 16;
comptime {
std.debug.assert(std.math.isPowerOfTwo(LOCK_STRIPES));
}
pub const FsCache = @This();
dir: std.fs.Dir,
locks: [LOCK_STRIPES]std.Thread.Mutex = .{std.Thread.Mutex{}} ** LOCK_STRIPES,
const CacheMetadataJson = struct {
version: usize,
metadata: CachedMetadata,
};
fn getLockPtr(self: *FsCache, key: *const [HASHED_KEY_LEN]u8) *std.Thread.Mutex {
const lock_idx = std.hash.Wyhash.hash(0, key[0..]) & (LOCK_STRIPES - 1);
return &self.locks[lock_idx];
}
const BODY_LEN_HEADER_LEN = 8;
const HASHED_KEY_LEN = 64;
const HASHED_PATH_LEN = HASHED_KEY_LEN + 6;
const HASHED_TMP_PATH_LEN = HASHED_PATH_LEN + 4;
fn hashKey(key: []const u8) [HASHED_KEY_LEN]u8 {
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(key, &digest, .{});
var hex: [HASHED_KEY_LEN]u8 = undefined;
_ = std.fmt.bufPrint(&hex, "{s}", .{std.fmt.bytesToHex(&digest, .lower)}) catch unreachable;
return hex;
}
fn cachePath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_PATH_LEN]u8 {
var path: [HASHED_PATH_LEN]u8 = undefined;
_ = std.fmt.bufPrint(&path, "{s}.cache", .{hashed_key}) catch unreachable;
return path;
}
fn cacheTmpPath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_TMP_PATH_LEN]u8 {
var path: [HASHED_TMP_PATH_LEN]u8 = undefined;
_ = std.fmt.bufPrint(&path, "{s}.cache.tmp", .{hashed_key}) catch unreachable;
return path;
}
pub fn init(path: []const u8) !FsCache {
const cwd = std.fs.cwd();
cwd.makeDir(path) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};
const dir = try cwd.openDir(path, .{ .iterate = true });
return .{ .dir = dir };
}
pub fn deinit(self: *FsCache) void {
self.dir.close();
}
pub fn get(self: *FsCache, arena: std.mem.Allocator, req: CacheRequest) ?Cache.CachedResponse {
const hashed_key = hashKey(req.url);
const cache_p = cachePath(&hashed_key);
const lock = self.getLockPtr(&hashed_key);
lock.lock();
defer lock.unlock();
const file = self.dir.openFile(&cache_p, .{ .mode = .read_only }) catch |e| {
switch (e) {
std.fs.File.OpenError.FileNotFound => {
log.debug(.cache, "miss", .{ .url = req.url, .hash = &hashed_key });
},
else => |err| {
log.warn(.cache, "open file err", .{ .url = req.url, .err = err });
},
}
return null;
};
var cleanup = false;
defer if (cleanup) {
file.close();
self.dir.deleteFile(&cache_p) catch |e| {
log.err(.cache, "clean fail", .{ .url = req.url, .file = &cache_p, .err = e });
};
};
var file_buf: [1024]u8 = undefined;
var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined;
var file_reader = file.reader(&file_buf);
const file_reader_iface = &file_reader.interface;
file_reader_iface.readSliceAll(&len_buf) catch |e| {
log.warn(.cache, "read header", .{ .url = req.url, .err = e });
cleanup = true;
return null;
};
const body_len = std.mem.readInt(u64, &len_buf, .little);
// Now we read metadata.
file_reader.seekTo(body_len + BODY_LEN_HEADER_LEN) catch |e| {
log.warn(.cache, "seek metadata", .{ .url = req.url, .err = e });
cleanup = true;
return null;
};
var json_reader = std.json.Reader.init(arena, file_reader_iface);
const cache_file: CacheMetadataJson = std.json.parseFromTokenSourceLeaky(
CacheMetadataJson,
arena,
&json_reader,
.{ .allocate = .alloc_always },
) catch |e| {
log.warn(.cache, "metadata parse", .{ .url = req.url, .err = e });
cleanup = true;
return null;
};
if (cache_file.version != CACHE_VERSION) {
log.warn(.cache, "version", .{ .url = req.url, .expected = CACHE_VERSION, .got = cache_file.version });
cleanup = true;
return null;
}
const metadata = cache_file.metadata;
// Check entry expiration.
const now = req.timestamp;
const age = (now - metadata.stored_at) + @as(i64, @intCast(metadata.age_at_store));
if (age < 0 or @as(u64, @intCast(age)) >= metadata.cache_control.max_age) {
log.debug(.cache, "expired", .{ .url = req.url });
cleanup = true;
return null;
}
// If we have Vary headers, ensure they are present & matching.
for (metadata.vary_headers) |vary_hdr| {
const name = vary_hdr.name;
const value = vary_hdr.value;
const incoming = for (req.request_headers) |h| {
if (std.ascii.eqlIgnoreCase(h.name, name)) break h.value;
} else "";
if (!std.ascii.eqlIgnoreCase(value, incoming)) {
log.debug(.cache, "vary mismatch", .{ .url = req.url, .header = name });
return null;
}
}
// On the case of a hash collision.
if (!std.ascii.eqlIgnoreCase(metadata.url, req.url)) {
log.warn(.cache, "collision", .{ .url = req.url, .expected = metadata.url, .got = req.url });
cleanup = true;
return null;
}
return .{
.metadata = metadata,
.data = .{
.file = .{
.file = file,
.offset = BODY_LEN_HEADER_LEN,
.len = body_len,
},
},
};
}
pub fn put(self: *FsCache, meta: CachedMetadata, body: []const u8) !void {
const hashed_key = hashKey(meta.url);
const cache_p = cachePath(&hashed_key);
const cache_tmp_p = cacheTmpPath(&hashed_key);
const lock = self.getLockPtr(&hashed_key);
lock.lock();
defer lock.unlock();
const file = try self.dir.createFile(&cache_tmp_p, .{ .truncate = true });
defer file.close();
var writer_buf: [1024]u8 = undefined;
var file_writer = file.writer(&writer_buf);
var file_writer_iface = &file_writer.interface;
var len_buf: [8]u8 = undefined;
std.mem.writeInt(u64, &len_buf, body.len, .little);
try file_writer_iface.writeAll(&len_buf);
try file_writer_iface.writeAll(body);
try std.json.Stringify.value(
CacheMetadataJson{ .version = CACHE_VERSION, .metadata = meta },
.{ .whitespace = .minified },
file_writer_iface,
);
try file_writer_iface.flush();
try self.dir.rename(&cache_tmp_p, &cache_p);
}
const testing = std.testing;
fn setupCache() !struct { tmp: testing.TmpDir, cache: Cache } {
var tmp = testing.tmpDir(.{});
errdefer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(testing.allocator, ".");
defer testing.allocator.free(path);
return .{
.tmp = tmp,
.cache = Cache{ .kind = .{ .fs = try FsCache.init(path) } },
};
}
test "FsCache: basic put and get" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
defer file.close();
var buf: [64]u8 = undefined;
var file_reader = file.reader(&buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
test "FsCache: get expiration" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = 5000;
const max_age = 1000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 900,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 50,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
result.data.file.file.close();
try testing.expectEqual(null, cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 200,
.request_headers = &.{},
},
));
try testing.expectEqual(null, cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
));
}
test "FsCache: put override" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
{
const now = 5000;
const max_age = 1000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 900,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
defer file.close();
var buf: [64]u8 = undefined;
var file_reader = file.reader(&buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
{
const now = 10000;
const max_age = 2000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
const body = "goodbye world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
defer file.close();
var buf: [64]u8 = undefined;
var file_reader = file.reader(&buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
}
test "FsCache: garbage file" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const hashed_key = hashKey("https://example.com");
const cache_p = cachePath(&hashed_key);
const file = try setup.cache.kind.fs.dir.createFile(&cache_p, .{});
try file.writeAll("this is not a valid cache file !@#$%");
file.close();
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
try testing.expectEqual(
null,
setup.cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = 5000,
.request_headers = &.{},
}),
);
}
test "FsCache: vary hit and miss" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
};
try cache.put(meta, "hello world");
const result = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
}) orelse return error.CacheMiss;
result.data.file.file.close();
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "br" },
},
}));
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
}));
const result2 = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
}) orelse return error.CacheMiss;
result2.data.file.file.close();
}
test "FsCache: vary multiple headers" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "en" },
},
};
try cache.put(meta, "hello world");
const result = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "en" },
},
}) orelse return error.CacheMiss;
result.data.file.file.close();
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "fr" },
},
}));
}

View File

@@ -28,9 +28,7 @@ pub const ENABLE_DEBUG = false;
pub const Blob = libcurl.CurlBlob; pub const Blob = libcurl.CurlBlob;
pub const WaitFd = libcurl.CurlWaitFd; pub const WaitFd = libcurl.CurlWaitFd;
pub const readfunc_pause = libcurl.curl_readfunc_pause;
pub const writefunc_error = libcurl.curl_writefunc_error; pub const writefunc_error = libcurl.curl_writefunc_error;
pub const WsFrameType = libcurl.WsFrameType;
const Error = libcurl.Error; const Error = libcurl.Error;
@@ -81,7 +79,7 @@ pub const Headers = struct {
self.headers = updated_headers; self.headers = updated_headers;
} }
fn parseHeader(header_str: []const u8) ?Header { pub fn parseHeader(header_str: []const u8) ?Header {
const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null; const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null;
const name = std.mem.trim(u8, header_str[0..colon_pos], " \t"); const name = std.mem.trim(u8, header_str[0..colon_pos], " \t");
@@ -90,22 +88,9 @@ pub const Headers = struct {
return .{ .name = name, .value = value }; return .{ .name = name, .value = value };
} }
pub fn iterator(self: *Headers) Iterator { pub fn iterator(self: Headers) HeaderIterator {
return .{ return .{ .curl_slist = .{ .header = self.headers } };
.header = self.headers,
};
} }
const Iterator = struct {
header: [*c]libcurl.CurlSList,
pub fn next(self: *Iterator) ?Header {
const h = self.header orelse return null;
self.header = h.*.next;
return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data))));
}
};
}; };
// In normal cases, the header iterator comes from the curl linked list. // In normal cases, the header iterator comes from the curl linked list.
@@ -114,6 +99,7 @@ pub const Headers = struct {
// This union, is an iterator that exposes the same API for either case. // This union, is an iterator that exposes the same API for either case.
pub const HeaderIterator = union(enum) { pub const HeaderIterator = union(enum) {
curl: CurlHeaderIterator, curl: CurlHeaderIterator,
curl_slist: CurlSListIterator,
list: ListHeaderIterator, list: ListHeaderIterator,
pub fn next(self: *HeaderIterator) ?Header { pub fn next(self: *HeaderIterator) ?Header {
@@ -122,6 +108,19 @@ pub const HeaderIterator = union(enum) {
} }
} }
pub fn collect(self: *HeaderIterator, allocator: std.mem.Allocator) !std.ArrayList(Header) {
var list: std.ArrayList(Header) = .empty;
while (self.next()) |hdr| {
try list.append(allocator, .{
.name = try allocator.dupe(u8, hdr.name),
.value = try allocator.dupe(u8, hdr.value),
});
}
return list;
}
const CurlHeaderIterator = struct { const CurlHeaderIterator = struct {
conn: *const Connection, conn: *const Connection,
prev: ?*libcurl.CurlHeader = null, prev: ?*libcurl.CurlHeader = null,
@@ -138,6 +137,16 @@ pub const HeaderIterator = union(enum) {
} }
}; };
const CurlSListIterator = struct {
header: [*c]libcurl.CurlSList,
pub fn next(self: *CurlSListIterator) ?Header {
const h = self.header orelse return null;
self.header = h.*.next;
return Headers.parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data))));
}
};
const ListHeaderIterator = struct { const ListHeaderIterator = struct {
index: usize = 0, index: usize = 0,
list: []const Header, list: []const Header,
@@ -213,19 +222,15 @@ pub const ResponseHead = struct {
pub const Connection = struct { pub const Connection = struct {
_easy: *libcurl.Curl, _easy: *libcurl.Curl,
transport: Transport,
node: std.DoublyLinkedList.Node = .{}, node: std.DoublyLinkedList.Node = .{},
pub const Transport = union(enum) { pub fn init(
none, // used for cases that manage their own connection, e.g. telemetry ca_blob: ?libcurl.CurlBlob,
http: *@import("../browser/HttpClient.zig").Transfer, config: *const Config,
websocket: *@import("../browser/webapi/net/WebSocket.zig"), ) !Connection {
};
pub fn init(ca_blob: ?libcurl.CurlBlob, config: *const Config) !Connection {
const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy; const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy;
var self = Connection{ ._easy = easy, .transport = .none }; const self = Connection{ ._easy = easy };
errdefer self.deinit(); errdefer self.deinit();
try self.reset(config, ca_blob); try self.reset(config, ca_blob);
@@ -305,12 +310,7 @@ pub const Connection = struct {
try libcurl.curl_easy_setopt(self._easy, .user_pwd, creds.ptr); try libcurl.curl_easy_setopt(self._easy, .user_pwd, creds.ptr);
} }
pub fn setConnectOnly(self: *const Connection, connect_only: bool) !void { pub fn setCallbacks(
const value: c_long = if (connect_only) 2 else 0;
try libcurl.curl_easy_setopt(self._easy, .connect_only, value);
}
pub fn setWriteCallback(
self: *Connection, self: *Connection,
comptime data_cb: libcurl.CurlWriteFunction, comptime data_cb: libcurl.CurlWriteFunction,
) !void { ) !void {
@@ -318,49 +318,12 @@ pub const Connection = struct {
try libcurl.curl_easy_setopt(self._easy, .write_function, data_cb); try libcurl.curl_easy_setopt(self._easy, .write_function, data_cb);
} }
pub fn setReadCallback(
self: *Connection,
comptime data_cb: libcurl.CurlReadFunction,
upload: bool,
) !void {
try libcurl.curl_easy_setopt(self._easy, .read_data, self);
try libcurl.curl_easy_setopt(self._easy, .read_function, data_cb);
if (upload) {
try libcurl.curl_easy_setopt(self._easy, .upload, true);
}
}
pub fn setHeaderCallback(
self: *Connection,
comptime data_cb: libcurl.CurlHeaderFunction,
) !void {
try libcurl.curl_easy_setopt(self._easy, .header_data, self);
try libcurl.curl_easy_setopt(self._easy, .header_function, data_cb);
}
pub const PauseFlags = packed struct {
red: bool = false,
green: bool = false,
blue: bool = false,
alpha: bool = false,
// Optional padding to match a specific size, e.g., a u32
_padding: u28 = 0,
};
pub fn pause(
self: *Connection,
flags: libcurl.CurlPauseFlags,
) !void {
try libcurl.curl_easy_pause(self._easy, flags);
}
pub fn reset( pub fn reset(
self: *Connection, self: *const Connection,
config: *const Config, config: *const Config,
ca_blob: ?libcurl.CurlBlob, ca_blob: ?libcurl.CurlBlob,
) !void { ) !void {
libcurl.curl_easy_reset(self._easy); libcurl.curl_easy_reset(self._easy);
self.transport = .none;
// timeouts // timeouts
try libcurl.curl_easy_setopt(self._easy, .timeout_ms, config.httpTimeout()); try libcurl.curl_easy_setopt(self._easy, .timeout_ms, config.httpTimeout());
@@ -497,6 +460,12 @@ pub const Connection = struct {
}; };
} }
pub fn getPrivate(self: *const Connection) !*anyopaque {
var private: *anyopaque = undefined;
try libcurl.curl_easy_getinfo(self._easy, .private, &private);
return private;
}
// These are headers that may not be send to the users for inteception. // These are headers that may not be send to the users for inteception.
pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void { pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void {
if (http_headers.proxy_bearer_header) |hdr| { if (http_headers.proxy_bearer_header) |hdr| {
@@ -513,14 +482,6 @@ pub const Connection = struct {
try libcurl.curl_easy_perform(self._easy); try libcurl.curl_easy_perform(self._easy);
return self.getResponseCode(); return self.getResponseCode();
} }
pub fn wsStartFrame(self: *const Connection, frame_type: libcurl.WsFrameType, size: usize) !void {
try libcurl.curl_ws_start_frame(self._easy, frame_type, @intCast(size));
}
pub fn wsMeta(self: *const Connection) ?libcurl.WsFrameMeta {
return libcurl.curl_ws_meta(self._easy);
}
}; };
pub const Handles = struct { pub const Handles = struct {
@@ -558,21 +519,17 @@ pub const Handles = struct {
} }
pub const MultiMessage = struct { pub const MultiMessage = struct {
conn: *Connection, conn: Connection,
err: ?Error, err: ?Error,
}; };
pub fn readMessage(self: *Handles) !?MultiMessage { pub fn readMessage(self: *Handles) ?MultiMessage {
var messages_count: c_int = 0; var messages_count: c_int = 0;
const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null; const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null;
return switch (msg.data) { return switch (msg.data) {
.done => |err| { .done => |err| .{
var private: *anyopaque = undefined; .conn = .{ ._easy = msg.easy_handle },
try libcurl.curl_easy_getinfo(msg.easy_handle, .private, &private); .err = err,
return .{
.conn = @ptrCast(@alignCast(private)),
.err = err,
};
}, },
else => unreachable, else => unreachable,
}; };

View File

@@ -40,8 +40,6 @@ pub const CurlDebugFunction = fn (*Curl, CurlInfoType, [*c]u8, usize, *anyopaque
pub const CurlHeaderFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; pub const CurlHeaderFunction = fn ([*]const u8, usize, usize, *anyopaque) usize;
pub const CurlWriteFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; pub const CurlWriteFunction = fn ([*]const u8, usize, usize, *anyopaque) usize;
pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR; pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR;
pub const curl_readfunc_pause: usize = c.CURL_READFUNC_PAUSE;
pub const CurlReadFunction = fn ([*]u8, usize, usize, *anyopaque) usize;
pub const FreeCallback = fn (ptr: ?*anyopaque) void; pub const FreeCallback = fn (ptr: ?*anyopaque) void;
pub const StrdupCallback = fn (str: [*:0]const u8) ?[*:0]u8; pub const StrdupCallback = fn (str: [*:0]const u8) ?[*:0]u8;
@@ -100,23 +98,6 @@ pub const CurlWaitFd = extern struct {
revents: CurlWaitEvents, revents: CurlWaitEvents,
}; };
pub const CurlPauseFlags = packed struct(c_short) {
recv: bool = false,
send: bool = false,
all: bool = false,
cont: bool = false,
_reserved: u12 = 0,
pub fn to_c(self: @This()) c_int {
var flags: c_int = 0;
if (self.recv) flags |= c.CURLPAUSE_RECV;
if (self.send) flags |= c.CURLPAUSE_SEND;
if (self.all) flags |= c.CURLPAUSE_ALL;
if (self.cont) flags |= c.CURLPAUSE_CONT;
return flags;
}
};
comptime { comptime {
const debug_cb_check: c.curl_debug_callback = struct { const debug_cb_check: c.curl_debug_callback = struct {
fn cb(handle: ?*Curl, msg_type: c.curl_infotype, raw: [*c]u8, len: usize, user: ?*anyopaque) callconv(.c) c_int { fn cb(handle: ?*Curl, msg_type: c.curl_infotype, raw: [*c]u8, len: usize, user: ?*anyopaque) callconv(.c) c_int {
@@ -186,10 +167,6 @@ pub const CurlOption = enum(c.CURLoption) {
header_function = c.CURLOPT_HEADERFUNCTION, header_function = c.CURLOPT_HEADERFUNCTION,
write_data = c.CURLOPT_WRITEDATA, write_data = c.CURLOPT_WRITEDATA,
write_function = c.CURLOPT_WRITEFUNCTION, write_function = c.CURLOPT_WRITEFUNCTION,
read_data = c.CURLOPT_READDATA,
read_function = c.CURLOPT_READFUNCTION,
connect_only = c.CURLOPT_CONNECT_ONLY,
upload = c.CURLOPT_UPLOAD,
}; };
pub const CurlMOption = enum(c.CURLMoption) { pub const CurlMOption = enum(c.CURLMoption) {
@@ -553,7 +530,6 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
const code = switch (option) { const code = switch (option) {
.verbose, .verbose,
.post, .post,
.upload,
.http_get, .http_get,
.ssl_verify_host, .ssl_verify_host,
.ssl_verify_peer, .ssl_verify_peer,
@@ -575,7 +551,6 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.max_redirs, .max_redirs,
.follow_location, .follow_location,
.post_field_size, .post_field_size,
.connect_only,
=> blk: { => blk: {
const n: c_long = switch (@typeInfo(@TypeOf(value))) { const n: c_long = switch (@typeInfo(@TypeOf(value))) {
.comptime_int, .int => @intCast(value), .comptime_int, .int => @intCast(value),
@@ -618,7 +593,6 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.private, .private,
.header_data, .header_data,
.read_data,
.write_data, .write_data,
=> blk: { => blk: {
const ptr: ?*anyopaque = switch (@typeInfo(@TypeOf(value))) { const ptr: ?*anyopaque = switch (@typeInfo(@TypeOf(value))) {
@@ -657,22 +631,6 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
break :blk c.curl_easy_setopt(easy, opt, cb); break :blk c.curl_easy_setopt(easy, opt, cb);
}, },
.read_function => blk: {
const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) {
.null => null,
.@"fn" => |info| struct {
fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize {
const user_arg = if (@typeInfo(info.params[3].type.?) == .optional)
user
else
user orelse unreachable;
return value(@ptrCast(buffer), count, len, user_arg);
}
}.cb,
else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))),
};
break :blk c.curl_easy_setopt(easy, opt, cb);
},
.write_function => blk: { .write_function => blk: {
const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) {
.null => null, .null => null,
@@ -719,10 +677,6 @@ pub fn curl_easy_getinfo(easy: *Curl, comptime info: CurlInfo, out: anytype) Err
try errorCheck(code); try errorCheck(code);
} }
pub fn curl_easy_pause(easy: *Curl, flags: CurlPauseFlags) Error!void {
try errorCheck(c.curl_easy_pause(easy, flags.to_c()));
}
pub fn curl_easy_header( pub fn curl_easy_header(
easy: *Curl, easy: *Curl,
name: [*:0]const u8, name: [*:0]const u8,
@@ -850,79 +804,3 @@ pub fn curl_slist_free_all(list: ?*CurlSList) void {
c.curl_slist_free_all(ptr); c.curl_slist_free_all(ptr);
} }
} }
// WebSocket support (requires libcurl 7.86.0+)
pub const WsFrameType = enum {
text,
binary,
cont,
close,
ping,
pong,
fn toInt(self: WsFrameType) c_uint {
return switch (self) {
.text => c.CURLWS_TEXT,
.binary => c.CURLWS_BINARY,
.cont => c.CURLWS_CONT,
.close => c.CURLWS_CLOSE,
.ping => c.CURLWS_PING,
.pong => c.CURLWS_PONG,
};
}
fn fromFlags(flags: c_int) WsFrameType {
const f: c_uint = @bitCast(flags);
if (f & c.CURLWS_TEXT != 0) return .text;
if (f & c.CURLWS_BINARY != 0) return .binary;
if (f & c.CURLWS_CLOSE != 0) return .close;
if (f & c.CURLWS_PING != 0) return .ping;
if (f & c.CURLWS_PONG != 0) return .pong;
if (f & c.CURLWS_CONT != 0) return .cont;
return .binary; // default fallback
}
};
pub const WsFrameMeta = struct {
frame_type: WsFrameType,
offset: usize,
bytes_left: usize,
len: usize,
fn from(frame: *const c.curl_ws_frame) WsFrameMeta {
return .{
.frame_type = WsFrameType.fromFlags(frame.flags),
.offset = @intCast(frame.offset),
.bytes_left = @intCast(frame.bytesleft),
.len = if (frame.len < 0)
std.math.maxInt(usize)
else
@intCast(frame.len),
};
}
};
pub fn curl_ws_send(easy: *Curl, buffer: []const u8, sent: *usize, fragsize: CurlOffT, frame_type: WsFrameType) Error!void {
try errorCheck(c.curl_ws_send(easy, buffer.ptr, buffer.len, sent, fragsize, frame_type.toInt()));
}
pub fn curl_ws_recv(easy: *Curl, buffer: []u8, recv: *usize, meta: *?WsFrameMeta) Error!void {
var c_meta: [*c]const c.curl_ws_frame = null;
const code = c.curl_ws_recv(easy, buffer.ptr, buffer.len, recv, &c_meta);
if (c_meta) |m| {
meta.* = WsFrameMeta.from(m);
} else {
meta.* = null;
}
try errorCheck(code);
}
pub fn curl_ws_meta(easy: *Curl) ?WsFrameMeta {
const ptr = c.curl_ws_meta(easy);
if (ptr == null) return null;
return WsFrameMeta.from(ptr);
}
pub fn curl_ws_start_frame(easy: *Curl, frame_type: WsFrameType, size: CurlOffT) Error!void {
try errorCheck(c.curl_ws_start_frame(easy, frame_type.toInt(), size));
}

View File

@@ -436,17 +436,19 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
if (js_val.isTrue()) { if (js_val.isTrue()) {
return; return;
} }
const sleep_ms: usize = switch (try runner.tick(.{ .ms = 20 })) { switch (try runner.tick(.{ .ms = 20 })) {
.done => 20, .done => return error.TestNeverSignaledCompletion,
.ok => |next_ms| @min(next_ms, 20), .ok => |next_ms| {
}; const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= wait_ms) {
const ms_elapsed = timer.lap() / 1_000_000; return error.TestTimedOut;
if (ms_elapsed >= wait_ms) { }
return error.TestTimedOut; wait_ms -= @intCast(ms_elapsed);
if (next_ms > 0) {
std.Thread.sleep(std.time.ns_per_ms * next_ms);
}
},
} }
wait_ms -= @intCast(ms_elapsed);
std.Thread.sleep(std.time.ns_per_ms * sleep_ms);
} }
} }
@@ -474,15 +476,12 @@ pub fn pageTest(comptime test_file: []const u8, opts: PageTestOpts) !*Page {
const log = @import("log.zig"); const log = @import("log.zig");
const TestHTTPServer = @import("TestHTTPServer.zig"); const TestHTTPServer = @import("TestHTTPServer.zig");
const TestWSServer = @import("TestWSServer.zig");
const Server = @import("Server.zig"); const Server = @import("Server.zig");
var test_cdp_server: ?*Server = null; var test_cdp_server: ?*Server = null;
var test_cdp_server_thread: ?std.Thread = null; var test_cdp_server_thread: ?std.Thread = null;
var test_http_server: ?TestHTTPServer = null; var test_http_server: ?TestHTTPServer = null;
var test_http_server_thread: ?std.Thread = null; var test_http_server_thread: ?std.Thread = null;
var test_ws_server: ?TestWSServer = null;
var test_ws_server_thread: ?std.Thread = null;
var test_config: Config = undefined; var test_config: Config = undefined;
@@ -515,16 +514,13 @@ test "tests:beforeAll" {
test_session = try test_browser.newSession(test_notification); test_session = try test_browser.newSession(test_notification);
var wg: std.Thread.WaitGroup = .{}; var wg: std.Thread.WaitGroup = .{};
wg.startMany(3); wg.startMany(2);
test_cdp_server_thread = try std.Thread.spawn(.{}, serveCDP, .{&wg}); test_cdp_server_thread = try std.Thread.spawn(.{}, serveCDP, .{&wg});
test_http_server = TestHTTPServer.init(testHTTPHandler); test_http_server = TestHTTPServer.init(testHTTPHandler);
test_http_server_thread = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &test_http_server.?, &wg }); test_http_server_thread = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &test_http_server.?, &wg });
test_ws_server = TestWSServer.init();
test_ws_server_thread = try std.Thread.spawn(.{}, TestWSServer.run, .{ &test_ws_server.?, &wg });
// need to wait for the servers to be listening, else tests will fail because // need to wait for the servers to be listening, else tests will fail because
// they aren't able to connect. // they aren't able to connect.
wg.wait(); wg.wait();
@@ -549,16 +545,6 @@ test "tests:afterAll" {
server.deinit(); server.deinit();
} }
if (test_ws_server) |*server| {
server.stop();
}
if (test_ws_server_thread) |thread| {
thread.join();
}
if (test_ws_server) |*server| {
server.deinit();
}
@import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size; @import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size;
test_notification.deinit(); test_notification.deinit();