Compare commits

...

92 Commits

Author SHA1 Message Date
Karl Seguin
c4250418af Address feedback
-dispatch error on abnormal close
-reciprocal close message
-more url validation
-cleanup dead code
2026-04-04 07:39:55 +08:00
Karl Seguin
6bf35e1ed4 try to improve test ws shutdown, merge ws tests 2026-04-04 07:00:26 +08:00
Karl Seguin
0d75c00f85 update v8 dep 2026-04-04 07:00:26 +08:00
Karl Seguin
6d83da5161 support 'blob' binaryType 2026-04-04 07:00:26 +08:00
Karl Seguin
14dcb7895a Give websockets their own connection pool, improve websocket message logging 2026-04-04 07:00:24 +08:00
Karl Seguin
5733c35a2d WebSocket WebAPI
Uses libcurl's websocket capabilities to add support for WebSocket.

Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/167
Issue: https://github.com/lightpanda-io/browser/issues/1952

This is a WIP because it currently uses the same connection pool used for all
HTTP requests. It would be pretty easy for a page to starve the pool and block
any progress.

We previously stored the *Transfer inside of the easy's private data. We now
store the *Connection, and a Connection now has a `transport` field which is
a union for `http: *Transfer` or `websocket: *Websocket`.
2026-04-04 06:59:28 +08:00
Pierre Tachoire
06fe6c5e7d Merge pull request #1934 from lightpanda-io/http-cache
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
HTTP Caching
2026-04-03 17:11:06 +02:00
Muki Kiboigo
778b7eb8c2 allocate CacheMetadata on use 2026-04-03 07:34:31 -07:00
Muki Kiboigo
ca5fa2b866 change --cache-dir -> --http-cache-dir 2026-04-03 07:23:32 -07:00
Pierre Tachoire
a71ff521aa cache: add debug log with no store reason 2026-04-03 07:23:32 -07:00
Muki Kiboigo
5a551607c2 better logging on FsCache init failure 2026-04-03 07:23:32 -07:00
Muki Kiboigo
13ea4d1ee3 more expressive cache logging 2026-04-03 07:23:32 -07:00
Muki Kiboigo
dc600c953f move script queue log before request 2026-04-03 07:23:32 -07:00
Muki Kiboigo
e00d569754 fix crashes on cached file from script manager 2026-04-03 07:23:31 -07:00
Muki Kiboigo
3d760e4577 add format to CachedMetadata 2026-04-03 07:23:31 -07:00
Muki Kiboigo
1e8bdd7e28 assign headers and vary headers before possible move 2026-04-03 07:23:31 -07:00
Muki Kiboigo
31bab4cc05 put in cache before releasing conn 2026-04-03 07:23:31 -07:00
Muki Kiboigo
a1a301666f dupe url in tryCache 2026-04-03 07:23:31 -07:00
Muki Kiboigo
619a2653d1 update cacheDir config option 2026-04-03 07:23:31 -07:00
Muki Kiboigo
0b9cae5354 fix self.req.ctx in HttpClient 2026-04-03 07:23:31 -07:00
Muki Kiboigo
f098a991a8 remove cache revalidation stubs 2026-04-03 07:23:30 -07:00
Muki Kiboigo
7b5e4d6f52 add Vary support 2026-04-03 07:23:30 -07:00
Muki Kiboigo
9ffc99d6a2 add more FsCache tests 2026-04-03 07:23:30 -07:00
Muki Kiboigo
855c3290ff always close file on serveFromCache 2026-04-03 07:23:30 -07:00
Muki Kiboigo
d65a4b09f3 better logging for cache 2026-04-03 07:23:30 -07:00
Muki Kiboigo
6a57d69359 switch to single file cache 2026-04-03 07:23:30 -07:00
Muki Kiboigo
9c5e67fbf5 properly deinit cache 2026-04-03 07:23:30 -07:00
Muki Kiboigo
7edb24e54d use wyhash for power of two lock stripes 2026-04-03 07:23:29 -07:00
Muki Kiboigo
a60932bbe0 require timestamp passed in with cache request 2026-04-03 07:23:29 -07:00
Muki Kiboigo
77e9f5caf7 remove unused cache method on fs cache 2026-04-03 07:23:29 -07:00
Muki Kiboigo
cedc894445 add basic fs cache get/put test 2026-04-03 07:23:29 -07:00
Muki Kiboigo
9d62e58c9a check age on fs cache get 2026-04-03 07:23:29 -07:00
Muki Kiboigo
609983da87 only store stuff when we know we will cache 2026-04-03 07:23:29 -07:00
Muki Kiboigo
65f77af84d shortcircuit a lot of caching checks 2026-04-03 07:23:29 -07:00
Muki Kiboigo
cd3e6b2364 ensure fs cache file is closed after use 2026-04-03 07:23:28 -07:00
Muki Kiboigo
557a4458a4 use CacheRequest instead of key 2026-04-03 07:23:28 -07:00
Muki Kiboigo
ce620e208d add striped lock to FsCache 2026-04-03 07:23:28 -07:00
Muki Kiboigo
2de35a9db2 use arena_pool for cache get 2026-04-03 07:23:28 -07:00
Muki Kiboigo
3eb05fdd1a use writer for fs cache body file 2026-04-03 07:23:28 -07:00
Muki Kiboigo
186fdee59b use json for fs cache metadata file 2026-04-03 07:23:28 -07:00
Muki Kiboigo
3c8bb5bc00 use sha256 instead of wyhash 2026-04-03 07:23:28 -07:00
Muki Kiboigo
66d190c047 store type_buf and sub_type_buf in Mime 2026-04-03 07:23:28 -07:00
Muki Kiboigo
5c2207ecc3 add more http caching rules 2026-04-03 07:23:27 -07:00
Muki Kiboigo
18d347e247 use CacheControl and Vary 2026-04-03 07:23:27 -07:00
Muki Kiboigo
29dfbbfdea cache headers along with response 2026-04-03 07:23:27 -07:00
Muki Kiboigo
02f611bbc8 add basic caching support 2026-04-03 07:23:27 -07:00
Muki Kiboigo
349d5a0a0b create cache owned by the network struct 2026-04-03 07:23:27 -07:00
Muki Kiboigo
647d989191 use enum approach instead of vtable 2026-04-03 07:23:27 -07:00
Muki Kiboigo
41a24623fa add basic FsCache impl 2026-04-03 07:23:27 -07:00
Muki Kiboigo
d9a3d912c0 add CachedResponse variant to Response 2026-04-03 07:23:26 -07:00
Muki Kiboigo
070baa8f46 add headerIterator to Http Response 2026-04-03 07:23:26 -07:00
Muki Kiboigo
4f78f299a3 use Response instead of Transfer in callbacks 2026-04-03 07:23:25 -07:00
Muki Kiboigo
9568c86326 allow Mime parse to use []const u8 2026-04-03 07:22:20 -07:00
Muki Kiboigo
6633b6effc add cache dir to configuration opts 2026-04-03 07:22:20 -07:00
Muki Kiboigo
2962864f3d create Cache interface file 2026-04-03 07:22:20 -07:00
Pierre Tachoire
749f21816c Merge pull request #2075 from lightpanda-io/e2e-integration-proxy
use proxy for integration tests
2026-04-03 14:23:10 +02:00
Pierre Tachoire
6bb8bc8391 ci: use proxy for integration tests 2026-04-03 09:24:17 +02:00
Karl Seguin
b6020e4770 Merge pull request #2066 from lightpanda-io/fix/agent-integration-and-mcp-cdp
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
mcp: improve navigation reliability and add CDP support
2026-04-03 08:27:55 +08:00
Karl Seguin
45f8f49bee Merge pull request #2074 from lightpanda-io/tao_in_identity_map
Store TAO in IdentityMap
2026-04-03 08:21:52 +08:00
Karl Seguin
494cc582fe Merge pull request #2073 from lightpanda-io/page-isSameOrigin
stricter Page.isSameOrigin
2026-04-03 07:39:30 +08:00
Karl Seguin
76db817089 Store TAO in IdentityMap
The object can be accessed from any context in the same origin, so the TAO
should exist for as long.
2026-04-03 07:34:52 +08:00
Karl Seguin
d2fb175d4f Merge pull request #2069 from lightpanda-io/finalizer_rc
Move finalizers to pure reference counting
2026-04-03 06:46:09 +08:00
Pierre Tachoire
082cd52b03 stricter Page.isSameOrigin
Page.isSameOrigin used to compare only the beginning of urls.
But origin https://origin.com must not match with
https://origin.com.attacker.com
2026-04-02 18:02:09 +02:00
Karl Seguin
476cf419f7 Merge pull request #2071 from lightpanda-io/abort_assertions
Relax assertion on httpclient abort
2026-04-02 22:50:41 +08:00
Adrià Arrufat
b29405749b server: handle CDPWaitResult.done instead of unreachable 2026-04-02 15:08:34 +02:00
Adrià Arrufat
62f58b4c12 browser: treat wait timeout as normal completion, not an error 2026-04-02 14:54:06 +02:00
Adrià Arrufat
69e5478dd7 browser: simplify Runner wait timeout logic 2026-04-02 14:15:15 +02:00
Karl Seguin
de0a04a58e Relax assertion on httpclient abort
It's ok to still have transfers, as long as whatever transfers still exists
are in an aborted state.
2026-04-02 17:59:17 +08:00
Karl Seguin
fec02850d4 Merge pull request #2068 from lightpanda-io/refactor/markdown-anchor-rendering
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
markdown: simplify and optimize anchor rendering
2026-04-02 17:06:26 +08:00
Karl Seguin
77b60cebb0 Move finalizers to pure reference counting
Takes https://github.com/lightpanda-io/browser/pull/2024 a step further and
changes all reference counting to be explicit.

Up until this point, finalizers_callback was seen as a fail-safe to make sure
that instances were released no matter what. It exists because v8 might never
call a finalizer, so we need to keep track of finalizables and finalize them
on behalf of v8. BUT, it was used as more than a fallback for v8...it allowed
us to be lazy and acquireRef's in Zig without a matching releaseRef (1), because
why not, the finalizer_callback will handle it.

This commit redefines finalizer_callbacks as strictly being a fallback for v8.
If v8 calls the finalizer, then the finalizer callback is removed (2) - we lose
our fail-safe. This means that every acquireRef must be matched with a
releaseRef. Everything is explicit now. The most obvious impact of this is
that on Page.deinit, we have to releaseRef every MO, IO and blob held by the
page.

This change removes a number of special-cases to deal with various ownership
patterns. For example, Iterators are now properly reference counted and when their
RC reaches 0, they can safely releaseRef on their list. This also elimites
use-after-free potential when 2 RC objects reference each other. This should
eliminate some WPT crashes (e.g. /editing/run/insertimage.html)

(1) - We were only ever lazy about releaseRef during shutdown, so this change
won't result in more aggressive collection.

(2) Since 1 object can be referenced from 0-N IsolatedWorlds, it would be more
accurate to say that the finalizer callback is removed when all referencing
IsolatedWorld finalize it.
2026-04-02 17:04:33 +08:00
Adrià Arrufat
71ac2e8c7f markdown: simplify and optimize anchor rendering 2026-04-02 09:11:26 +02:00
Adrià Arrufat
1770dc03e3 refactor: move timeout and busy logic to Runner 2026-04-02 08:06:50 +02:00
Karl Seguin
38fa9602fa Merge pull request #2067 from lightpanda-io/percent-encode-version
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
percent encode version query string for crash report
2026-04-02 07:50:31 +08:00
Pierre Tachoire
9661204c8d percent encode version query string for crash report 2026-04-01 22:15:56 +02:00
Karl Seguin
6800e53b0e Merge pull request #2014 from lightpanda-io/build-check
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
build: add check step to verify compilation
2026-04-01 21:05:04 +08:00
Adrià Arrufat
1854627b69 mcp: final protocol cleanup after removing screenshot tool
- Removed unused ImageContent from protocol.
- Simplified CallToolResult back to only support TextContent.
- Cleaned up CallToolResult usages in tools.zig.
2026-04-01 15:00:55 +02:00
Adrià Arrufat
fffa8b6d4b mcp/cdp: fix inactivity timeout
- Fixed CDP inactivity timeout by resetting it when the browser is busy (loading or executing macrotasks).
- Removed the placeholder screenshot tool.
- Refactored MCP tool schemas to constants to avoid duplication.
2026-04-01 14:37:40 +02:00
Nikolay Govorov
e79da3a4ad Merge pull request #2064 from lightpanda-io/network_naming
Improve network naming consistency
2026-04-01 13:10:12 +01:00
Karl Seguin
145792c4f5 Merge pull request #2061 from lightpanda-io/ariaAtomic
Add Element.ariaAtomic and Element.ariaLive properties
2026-04-01 20:06:15 +08:00
Karl Seguin
0bb3e3827d Merge pull request #2060 from lightpanda-io/HTMLAnchorElement.rel
Add HTMLAnchorElement.rel property
2026-04-01 20:04:43 +08:00
Karl Seguin
6e6e6e6fad Merge pull request #2057 from lightpanda-io/element-title
Add HTMLElement.title property
2026-04-01 19:36:12 +08:00
Karl Seguin
9d13a7ccdb Merge pull request #2065 from lightpanda-io/browser/resolve-scheme-in-path
Browser/resolve scheme in path
2026-04-01 19:30:40 +08:00
Karl Seguin
7fcaa500d8 Fix typo in variable name
protect against overflow if path stats with ':'

Minor tweaks to https://github.com/lightpanda-io/browser/pull/2046
2026-04-01 19:20:55 +08:00
Karl Seguin
0604056f76 Improve network naming consistency
1.
Runtime.zig -> Network.zig (especially since most places imported it as
`const Network = @import("Runtime.zig")`

2.
const net_http = @import(...) -> const http = @import(...)
2026-04-01 18:46:03 +08:00
Adrià Arrufat
58fc60d669 mcp: improve navigation reliability and add CDP support
- Configurable navigation timeouts and wait strategies in MCP tools.
- Default navigation timeout increased from 2s to 10s.
- Added navigate, eval, and screenshot MCP tools.
- Supported running a CDP server alongside MCP using --cdp-port.
- Fixed various startup crashes when running CDP in MCP mode.
- Hardened MCP server error handling.
2026-04-01 12:41:56 +02:00
Pierre Tachoire
5965d37c79 Add HTMLAnchorElement.rel property
Reflects the `rel` HTML attribute. The `relList` DOMTokenList was
already implemented but the string `rel` accessor was missing.
2026-04-01 11:15:10 +02:00
Pierre Tachoire
e430051fff Add Element.ariaAtomic and Element.ariaLive properties
ARIAMixin attribute reflection on Element, per the ARIA spec.
2026-04-01 11:13:52 +02:00
Pierre Tachoire
e412dfed2f Add HTMLElement.title property
Reflects the `title` HTML attribute as a getter/setter on HTMLElement.
2026-04-01 09:15:34 +02:00
dinisimys2018
2d87f5bf47 fix(browser-url): handle specific file scheme and change error InvalidURL to TypeError 2026-03-31 18:42:03 +03:00
dinisimys2018
0a222ff397 fix(browser-url): add more combinations base+path handle 2026-03-31 16:54:06 +03:00
dinisimys2018
9a0cefad26 fix(browser-url): url resolve scheme in path 2026-03-30 18:58:19 +03:00
Adrià Arrufat
3aeba97fc9 build: add check step to verify compilation 2026-03-27 14:25:17 +09:00
54 changed files with 4252 additions and 640 deletions

View File

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

View File

@@ -62,7 +62,7 @@ jobs:
- name: run end to end integration tests
continue-on-error: true
run: |
./lightpanda serve --log-level error & echo $! > LPD.pid
./lightpanda serve --http-proxy ${{ secrets.MASSIVE_PROXY_RESIDENTIAL_US }} --log-level error & echo $! > LPD.pid
go run integration/main.go |tee result.log
kill `cat LPD.pid`

View File

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

View File

@@ -46,8 +46,12 @@ pub fn build(b: *Build) !void {
var stdout = std.fs.File.stdout().writer(&.{});
try stdout.interface.print("Lightpanda {f}\n", .{version});
const version_string = b.fmt("{f}", .{version});
const version_encoded = std.mem.replaceOwned(u8, b.allocator, version_string, "+", "%2B") catch @panic("OOM");
var opts = b.addOptions();
opts.addOption([]const u8, "version", b.fmt("{f}", .{version}));
opts.addOption([]const u8, "version", version_string);
opts.addOption([]const u8, "version_encoded", version_encoded);
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
@@ -85,6 +89,15 @@ pub fn build(b: *Build) !void {
break :blk mod;
};
// Check compilation
const check = b.step("check", "Check if lightpanda compiles");
const check_lib = b.addLibrary(.{
.name = "lightpanda_check",
.root_module = lightpanda_module,
});
check.dependOn(&check_lib.step);
{
// browser
const exe = b.addExecutable(.{
@@ -103,6 +116,12 @@ pub fn build(b: *Build) !void {
});
b.installArtifact(exe);
const exe_check = b.addLibrary(.{
.name = "lightpanda_exe_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
@@ -132,6 +151,12 @@ pub fn build(b: *Build) !void {
});
b.installArtifact(exe);
const exe_check = b.addLibrary(.{
.name = "snapshot_creator_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
@@ -170,6 +195,12 @@ pub fn build(b: *Build) !void {
});
b.installArtifact(exe);
const exe_check = b.addLibrary(.{
.name = "legacy_test_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
@@ -462,6 +493,7 @@ fn buildCurl(
.CURL_DISABLE_SMTP = true,
.CURL_DISABLE_TELNET = true,
.CURL_DISABLE_TFTP = true,
.CURL_DISABLE_WEBSOCKETS = false, // Enable WebSocket support
.ssize_t = null,
._FILE_OFFSET_BITS = 64,

View File

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

View File

@@ -26,7 +26,7 @@ const Snapshot = @import("browser/js/Snapshot.zig");
const Platform = @import("browser/js/Platform.zig");
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const Network = @import("network/Runtime.zig");
const Network = @import("network/Network.zig");
pub const ArenaPool = @import("ArenaPool.zig");
const App = @This();
@@ -55,7 +55,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
.arena_pool = undefined,
};
app.network = try Network.init(allocator, config);
app.network = try Network.init(allocator, app, config);
errdefer app.network.deinit();
app.platform = try Platform.init();

View File

@@ -34,7 +34,6 @@ pub const RunMode = enum {
mcp,
};
pub const MAX_LISTENERS = 16;
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
// max message size
@@ -129,6 +128,13 @@ pub fn httpMaxResponseSize(self: *const Config) ?usize {
};
}
pub fn wsMaxConcurrent(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.ws_max_concurrent orelse 8,
else => unreachable,
};
}
pub fn logLevel(self: *const Config) ?log.Level {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.log_level,
@@ -157,9 +163,17 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
};
}
pub fn httpCacheDir(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_cache_dir,
else => null,
};
}
pub fn cdpTimeout(self: *const Config) usize {
return switch (self.mode) {
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
.mcp => 10000, // Default timeout for MCP-CDP
else => unreachable,
};
}
@@ -167,6 +181,7 @@ pub fn cdpTimeout(self: *const Config) usize {
pub fn port(self: *const Config) u16 {
return switch (self.mode) {
.serve => |opts| opts.port,
.mcp => |opts| opts.cdp_port orelse 0,
else => unreachable,
};
}
@@ -174,6 +189,7 @@ pub fn port(self: *const Config) u16 {
pub fn advertiseHost(self: *const Config) []const u8 {
return switch (self.mode) {
.serve => |opts| opts.advertise_host orelse opts.host,
.mcp => "127.0.0.1",
else => unreachable,
};
}
@@ -192,6 +208,7 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
pub fn maxConnections(self: *const Config) u16 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_connections,
.mcp => 16,
else => unreachable,
};
}
@@ -199,6 +216,7 @@ pub fn maxConnections(self: *const Config) u16 {
pub fn maxPendingConnections(self: *const Config) u31 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_pending_connections,
.mcp => 128,
else => unreachable,
};
}
@@ -224,6 +242,7 @@ pub const Serve = struct {
pub const Mcp = struct {
common: Common = .{},
version: mcp.Version = .default,
cdp_port: ?u16 = null,
};
pub const DumpFormat = enum {
@@ -263,11 +282,13 @@ pub const Common = struct {
http_timeout: ?u31 = null,
http_connect_timeout: ?u31 = null,
http_max_response_size: ?usize = null,
ws_max_concurrent: ?u8 = null,
tls_verify_host: bool = true,
log_level: ?log.Level = null,
log_format: ?log.Format = null,
log_filter_scopes: ?[]log.Scope = null,
user_agent_suffix: ?[]const u8 = null,
http_cache_dir: ?[]const u8 = null,
web_bot_auth_key_file: ?[]const u8 = null,
web_bot_auth_keyid: ?[]const u8 = null,
@@ -362,6 +383,10 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ (e.g. XHR, fetch, script loading, ...).
\\ Defaults to no limit.
\\
\\--ws-max-concurrent
\\ The maximum number of concurrent WebSocket connections.
\\ Defaults to 8.
\\
\\--log-level The log level: debug, info, warn, error or fatal.
\\ Defaults to
++ (if (builtin.mode == .Debug) " info." else "warn.") ++
@@ -387,6 +412,11 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\
\\--web-bot-auth-domain
\\ Your domain e.g. yourdomain.com
\\
\\--http-cache-dir
\\ Path to a directory to use as a Filesystem Cache for network resources.
\\ Omitting this will result is no caching.
\\ Defaults to no caching.
;
// MAX_HELP_LEN|
@@ -677,6 +707,19 @@ fn parseMcpArgs(
continue;
}
if (std.mem.eql(u8, "--cdp-port", opt) or std.mem.eql(u8, "--cdp_port", opt)) {
const str = args.next() orelse {
log.fatal(.mcp, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
result.cdp_port = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.mcp, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument;
};
continue;
}
if (try parseCommonArg(allocator, opt, args, &result.common)) {
continue;
}
@@ -952,6 +995,19 @@ fn parseCommonArg(
return true;
}
if (std.mem.eql(u8, "--ws-max-concurrent", opt) or std.mem.eql(u8, "--ws_max_concurrent", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
common.ws_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log-level", opt) or std.mem.eql(u8, "--log_level", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
@@ -1048,5 +1104,14 @@ fn parseCommonArg(
return true;
}
if (std.mem.eql(u8, "--http-cache-dir", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http-cache-dir" });
return error.InvalidArgument;
};
common.http_cache_dir = try allocator.dupe(u8, str);
return true;
}
return false;
}

View File

@@ -297,13 +297,12 @@ pub const Client = struct {
}
var cdp = &self.mode.cdp;
var last_message = milliTimestamp(.monotonic);
var ms_remaining = self.ws.timeout_ms;
const timeout_ms = self.ws.timeout_ms;
while (true) {
const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) {
const result = cdp.pageWait(timeout_ms) catch |wait_err| switch (wait_err) {
error.NoPage => {
const status = http.tick(ms_remaining) catch |err| {
const status = http.tick(timeout_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
};
@@ -314,8 +313,6 @@ pub const Client = struct {
if (self.readSocket() == false) {
return;
}
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
continue;
},
else => return wait_err,
@@ -326,18 +323,10 @@ pub const Client = struct {
if (self.readSocket() == false) {
return;
}
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
},
.done => {
const now = milliTimestamp(.monotonic);
const elapsed = now - last_message;
if (elapsed >= ms_remaining) {
log.info(.app, "CDP timeout", .{});
return;
}
ms_remaining -= @intCast(elapsed);
last_message = now;
log.info(.app, "CDP timeout", .{});
return;
},
}
}

366
src/TestWSServer.zig Normal file
View File

@@ -0,0 +1,366 @@
// 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 stop(self: *TestWSServer) void {
self.shutdown.store(true, .release);
if (self.listener) |socket| {
switch (@import("builtin").target.os.tag) {
.linux => std.posix.shutdown(socket, .recv) catch {},
else => std.posix.close(socket),
}
}
}
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]);
}

View File

@@ -28,10 +28,14 @@ const URL = @import("URL.zig");
const Config = @import("../Config.zig");
const Notification = @import("../Notification.zig");
const CookieJar = @import("webapi/storage/Cookie.zig").Jar;
const WebSocket = @import("webapi/net/WebSocket.zig");
const http = @import("../network/http.zig");
const Runtime = @import("../network/Runtime.zig");
const Network = @import("../network/Network.zig");
const Robots = @import("../network/Robots.zig");
const Cache = @import("../network/cache/Cache.zig");
const CacheMetadata = Cache.CachedMetadata;
const CachedResponse = Cache.CachedResponse;
const IS_DEBUG = builtin.mode == .Debug;
@@ -86,7 +90,7 @@ queue: std.DoublyLinkedList = .{},
// The main app allocator
allocator: Allocator,
network: *Runtime,
network: *Network,
// Queue of requests that depend on a robots.txt.
// Allows us to fetch the robots.txt just once.
@@ -113,6 +117,8 @@ obey_robots: bool,
cdp_client: ?CDPClient = null,
max_response_size: usize,
// libcurl can monitor arbitrary sockets, this lets us use libcurl to poll
// both HTTP data as well as messages from an CDP connection.
// Furthermore, we have some tension between blocking scripts and request
@@ -131,7 +137,7 @@ pub const CDPClient = struct {
blocking_read_end: *const fn (*anyopaque) bool,
};
pub fn init(allocator: Allocator, network: *Runtime) !*Client {
pub fn init(allocator: Allocator, network: *Network) !*Client {
var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator);
errdefer transfer_pool.deinit();
@@ -153,6 +159,7 @@ pub fn init(allocator: Allocator, network: *Runtime) !*Client {
.http_proxy = http_proxy,
.tls_verify = network.config.tlsVerifyHost(),
.obey_robots = network.config.obeyRobots(),
.max_response_size = network.config.httpMaxResponseSize() orelse std.math.maxInt(u32),
};
return client;
@@ -221,24 +228,22 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
while (n) |node| {
n = node.next;
const conn: *http.Connection = @fieldParentPtr("node", node);
var transfer = Transfer.fromConnection(conn) catch |err| {
// Let's cleanup what we can
self.removeConn(conn);
log.err(.http, "get private info", .{ .err = err, .source = "abort" });
continue;
};
if (comptime abort_all) {
transfer.kill();
} else if (transfer.req.frame_id == frame_id) {
transfer.kill();
switch (conn.transport) {
.http => |transfer| {
if ((comptime abort_all) or transfer.req.frame_id == frame_id) {
transfer.kill();
}
},
.websocket => |ws| {
if ((comptime abort_all) or ws._page._frame_id == frame_id) {
ws.kill();
}
},
.none => unreachable,
}
}
}
if (comptime IS_DEBUG and abort_all) {
std.debug.assert(self.active == 0);
}
{
var q = &self.queue;
var n = q.first;
@@ -259,12 +264,20 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
}
if (comptime IS_DEBUG and abort_all) {
std.debug.assert(self.in_use.first == null);
const running = self.handles.perform() catch |err| {
lp.assert(false, "multi perform in abort", .{ .err = err });
};
std.debug.assert(running == 0);
// Even after an abort_all, we could still have transfers, but, at the
// very least, they should all be flagged as aborted.
var it = self.in_use.first;
var leftover: usize = 0;
while (it) |node| : (it = node.next) {
const conn: *http.Connection = @fieldParentPtr("node", node);
switch (conn.transport) {
.http => |transfer| std.debug.assert(transfer.aborted),
.websocket => {},
.none => {},
}
leftover += 1;
}
std.debug.assert(self.active == leftover);
}
}
@@ -311,7 +324,73 @@ pub fn request(self: *Client, req: Request) !void {
return self.fetchRobotsThenProcessRequest(robots_url, req);
}
fn serveFromCache(req: Request, cached: *const CachedResponse) !void {
const response = Response.fromCached(req.ctx, cached);
defer switch (cached.data) {
.buffer => |_| {},
.file => |f| f.file.close(),
};
if (req.start_callback) |cb| {
try cb(response);
}
const proceed = try req.header_callback(response);
if (!proceed) {
req.error_callback(req.ctx, error.Abort);
return;
}
switch (cached.data) {
.buffer => |data| {
if (data.len > 0) {
try req.data_callback(response, data);
}
},
.file => |f| {
const file = f.file;
var buf: [1024]u8 = undefined;
var file_reader = file.reader(&buf);
try file_reader.seekTo(f.offset);
const reader = &file_reader.interface;
var read_buf: [1024]u8 = undefined;
var remaining = f.len;
while (remaining > 0) {
const read_len = @min(read_buf.len, remaining);
const n = try reader.readSliceShort(read_buf[0..read_len]);
if (n == 0) break;
remaining -= n;
try req.data_callback(response, read_buf[0..n]);
}
},
}
try req.done_callback(req.ctx);
}
fn processRequest(self: *Client, req: Request) !void {
if (self.network.cache) |*cache| {
if (req.method == .GET) {
const arena = try self.network.app.arena_pool.acquire(.{ .debug = "HttpClient.processRequest.cache" });
defer self.network.app.arena_pool.release(arena);
var iter = req.headers.iterator();
const req_header_list = try iter.collect(arena);
if (cache.get(arena, .{
.url = req.url,
.timestamp = std.time.timestamp(),
.request_headers = req_header_list.items,
})) |cached| {
defer req.headers.deinit();
return serveFromCache(req, &cached);
}
}
}
const transfer = try self.makeTransfer(req);
transfer.req.notification.dispatch(.http_request_start, &.{ .transfer = transfer });
@@ -399,8 +478,10 @@ fn fetchRobotsThenProcessRequest(self: *Client, robots_url: [:0]const u8, req: R
try entry.value_ptr.append(self.allocator, req);
}
fn robotsHeaderCallback(transfer: *Transfer) !bool {
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(transfer.ctx));
fn robotsHeaderCallback(response: Response) !bool {
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(response.ctx));
// Robots callbacks only happen on real live requests.
const transfer = response.inner.transfer;
if (transfer.response_header) |hdr| {
log.debug(.browser, "robots status", .{ .status = hdr.status, .robots_url = ctx.robots_url });
@@ -414,8 +495,8 @@ fn robotsHeaderCallback(transfer: *Transfer) !bool {
return true;
}
fn robotsDataCallback(transfer: *Transfer, data: []const u8) !void {
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(transfer.ctx));
fn robotsDataCallback(response: Response, data: []const u8) !void {
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(response.ctx));
try ctx.buffer.appendSlice(ctx.client.allocator, data);
}
@@ -634,13 +715,42 @@ fn makeTransfer(self: *Client, req: Request) !*Transfer {
.id = id,
.url = req.url,
.req = req,
.ctx = req.ctx,
.client = self,
.max_response_size = self.network.config.httpMaxResponseSize(),
};
return transfer;
}
fn requestFailed(transfer: *Transfer, err: anyerror, comptime execute_callback: bool) void {
if (transfer._notified_fail) {
// we can force a failed request within a callback, which will eventually
// result in this being called again in the more general loop. We do this
// because we can raise a more specific error inside a callback in some cases
return;
}
transfer._notified_fail = true;
transfer.req.notification.dispatch(.http_request_fail, &.{
.transfer = transfer,
.err = err,
});
if (execute_callback) {
transfer.req.error_callback(transfer.req.ctx, err);
} else if (transfer.req.shutdown_callback) |cb| {
cb(transfer.req.ctx);
}
}
// Same restriction as changeProxy. Should be ok since this is only called on
// BrowserContext deinit.
pub fn restoreOriginalProxy(self: *Client) !void {
try self.ensureNoActiveConnection();
self.http_proxy = self.network.config.httpProxy();
self.use_proxy = self.http_proxy != null;
}
fn makeRequest(self: *Client, conn: *http.Connection, transfer: *Transfer) anyerror!void {
{
// Reset per-response state for retries (auth challenge, queue).
@@ -663,18 +773,14 @@ fn makeRequest(self: *Client, conn: *http.Connection, transfer: *Transfer) anyer
// fails BEFORE `curl_multi_add_handle` succeeds, the we still need to do
// cleanup. But if things fail after `curl_multi_add_handle`, we expect
// perfom to pickup the failure and cleanup.
self.in_use.append(&conn.node);
self.handles.add(conn) catch |err| {
self.trackConn(conn) catch |err| {
transfer._conn = null;
transfer.deinit();
self.in_use.remove(&conn.node);
self.releaseConn(conn);
return err;
};
self.active += 1;
if (transfer.req.start_callback) |cb| {
cb(transfer) catch |err| {
cb(Response.fromTransfer(transfer)) catch |err| {
transfer.deinit();
return err;
};
@@ -695,7 +801,7 @@ fn perform(self: *Client, timeout_ms: c_int) anyerror!PerformStatus {
break :blk try self.handles.perform();
};
// Process dirty connections — return them to Runtime pool.
// Process dirty connections — return them to Network pool.
while (self.dirty.popFirst()) |node| {
const conn: *http.Connection = @fieldParentPtr("node", node);
self.handles.remove(conn) catch |err| {
@@ -735,14 +841,17 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
// Also check on RecvError: proxy may send 407 with headers before
// closing the connection (CONNECT tunnel not yet established).
if (msg.err == null or msg.err.? == error.RecvError) {
transfer.detectAuthChallenge(&msg.conn);
transfer.detectAuthChallenge(msg.conn);
}
// In case of auth challenge
// TODO give a way to configure the number of auth retries.
if (transfer._auth_challenge != null and transfer._tries < 10) {
var wait_for_interception = false;
transfer.req.notification.dispatch(.http_request_auth_required, &.{ .transfer = transfer, .wait_for_interception = &wait_for_interception });
transfer.req.notification.dispatch(
.http_request_auth_required,
&.{ .transfer = transfer, .wait_for_interception = &wait_for_interception },
);
if (wait_for_interception) {
self.intercepted += 1;
if (comptime IS_DEBUG) {
@@ -834,17 +943,18 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
if (!transfer._header_done_called) {
// In case of request w/o data, we need to call the header done
// callback now.
const proceed = try transfer.headerDoneCallback(&msg.conn);
const proceed = try transfer.headerDoneCallback(msg.conn);
if (!proceed) {
transfer.requestFailed(error.Abort, true);
return true;
}
}
const body = transfer._stream_buffer.items;
// Replay buffered body through user's data_callback.
if (transfer._stream_buffer.items.len > 0) {
const body = transfer._stream_buffer.items;
try transfer.req.data_callback(transfer, body);
try transfer.req.data_callback(Response.fromTransfer(transfer), body);
transfer.req.notification.dispatch(.http_response_data, &.{
.data = body,
@@ -857,11 +967,19 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
}
}
if (transfer._pending_cache_metadata) |metadata| {
const cache = &self.network.cache.?;
cache.put(metadata.*, body) catch |err| {
log.warn(.cache, "cache put failed", .{ .err = err });
};
}
// release conn ASAP so that it's available; some done_callbacks
// will load more resources.
transfer.releaseConn();
try transfer.req.done_callback(transfer.ctx);
try transfer.req.done_callback(transfer.req.ctx);
transfer.req.notification.dispatch(.http_request_done, &.{
.transfer = transfer,
});
@@ -871,30 +989,63 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
fn processMessages(self: *Client) !bool {
var processed = false;
while (self.handles.readMessage()) |msg| {
const transfer = try Transfer.fromConnection(&msg.conn);
const done = self.processOneMessage(msg, transfer) catch |err| blk: {
log.err(.http, "process_messages", .{ .err = err, .req = transfer });
transfer.requestFailed(err, true);
if (transfer._detached_conn) |c| {
// Conn was removed from handles during redirect reconfiguration
// but not re-added. Release it directly to avoid double-remove.
self.in_use.remove(&c.node);
self.active -= 1;
self.releaseConn(c);
transfer._detached_conn = null;
}
break :blk true;
};
if (done) {
transfer.deinit();
processed = true;
while (try self.handles.readMessage()) |msg| {
switch (msg.conn.transport) {
.http => |transfer| {
const done = self.processOneMessage(msg, transfer) catch |err| blk: {
log.err(.http, "process_messages", .{ .err = err, .req = transfer });
transfer.requestFailed(err, true);
if (transfer._detached_conn) |c| {
// Conn was removed from handles during redirect reconfiguration
// but not re-added. Release it directly to avoid double-remove.
self.in_use.remove(&c.node);
self.active -= 1;
self.releaseConn(c);
transfer._detached_conn = null;
}
break :blk true;
};
if (done) {
transfer.deinit();
processed = true;
}
},
.websocket => |ws| {
if (msg.err) |err| switch (err) {
error.GotNothing => ws.disconnected(null),
else => ws.disconnected(err),
} else {
// Clean close - no error
ws.disconnected(null);
}
processed = true;
},
.none => unreachable,
}
}
return processed;
}
fn removeConn(self: *Client, conn: *http.Connection) void {
pub fn trackConn(self: *Client, conn: *http.Connection) !void {
self.in_use.append(&conn.node);
// Set private pointer so readMessage can find the Connection.
// Must be done each time since curl_easy_reset clears it when
// connections are returned to pool.
conn.setPrivate(conn) catch |err| {
self.in_use.remove(&conn.node);
self.releaseConn(conn);
return err;
};
self.handles.add(conn) catch |err| {
self.in_use.remove(&conn.node);
self.releaseConn(conn);
return err;
};
self.active += 1;
}
pub fn removeConn(self: *Client, conn: *http.Connection) void {
self.in_use.remove(&conn.node);
self.active -= 1;
if (self.handles.remove(conn)) {
@@ -927,7 +1078,6 @@ pub const Request = struct {
resource_type: ResourceType,
credentials: ?[:0]const u8 = null,
notification: *Notification,
max_response_size: ?usize = null,
// This is only relevant for intercepted requests. If a request is flagged
// as blocking AND is intercepted, then it'll be up to us to wait until
@@ -939,9 +1089,9 @@ pub const Request = struct {
// arbitrary data that can be associated with this request
ctx: *anyopaque = undefined,
start_callback: ?*const fn (transfer: *Transfer) anyerror!void = null,
header_callback: *const fn (transfer: *Transfer) anyerror!bool,
data_callback: *const fn (transfer: *Transfer, data: []const u8) anyerror!void,
start_callback: ?*const fn (response: Response) anyerror!void = null,
header_callback: *const fn (response: Response) anyerror!bool,
data_callback: *const fn (response: Response, data: []const u8) anyerror!void,
done_callback: *const fn (ctx: *anyopaque) anyerror!void,
error_callback: *const fn (ctx: *anyopaque, err: anyerror) void,
shutdown_callback: ?*const fn (ctx: *anyopaque) void = null,
@@ -967,21 +1117,94 @@ pub const Request = struct {
};
};
pub const Response = struct {
ctx: *anyopaque,
inner: union(enum) {
transfer: *Transfer,
cached: *const CachedResponse,
},
pub fn fromTransfer(transfer: *Transfer) Response {
return .{ .ctx = transfer.req.ctx, .inner = .{ .transfer = transfer } };
}
pub fn fromCached(ctx: *anyopaque, resp: *const CachedResponse) Response {
return .{ .ctx = ctx, .inner = .{ .cached = resp } };
}
pub fn status(self: Response) ?u16 {
return switch (self.inner) {
.transfer => |t| if (t.response_header) |rh| rh.status else null,
.cached => |c| c.metadata.status,
};
}
pub fn contentType(self: Response) ?[]const u8 {
return switch (self.inner) {
.transfer => |t| if (t.response_header) |*rh| rh.contentType() else null,
.cached => |c| c.metadata.content_type,
};
}
pub fn contentLength(self: Response) ?u32 {
return switch (self.inner) {
.transfer => |t| t.getContentLength(),
.cached => |c| switch (c.data) {
.buffer => |buf| @intCast(buf.len),
.file => |f| @intCast(f.len),
},
};
}
pub fn redirectCount(self: Response) ?u32 {
return switch (self.inner) {
.transfer => |t| if (t.response_header) |rh| rh.redirect_count else null,
.cached => 0,
};
}
pub fn url(self: Response) [:0]const u8 {
return switch (self.inner) {
.transfer => |t| t.url,
.cached => |c| c.metadata.url,
};
}
pub fn headerIterator(self: Response) HeaderIterator {
return switch (self.inner) {
.transfer => |t| t.responseHeaderIterator(),
.cached => |c| HeaderIterator{ .list = .{ .list = c.metadata.headers } },
};
}
pub fn abort(self: Response, err: anyerror) void {
switch (self.inner) {
.transfer => |t| t.abort(err),
.cached => {},
}
}
pub fn format(self: Response, writer: *std.Io.Writer) !void {
return switch (self.inner) {
.transfer => |t| try t.format(writer),
.cached => |c| try c.format(writer),
};
}
};
pub const Transfer = struct {
arena: ArenaAllocator,
id: u32 = 0,
req: Request,
url: [:0]const u8,
ctx: *anyopaque, // copied from req.ctx to make it easier for callback handlers
client: *Client,
// total bytes received in the response, including the response status line,
// the headers, and the [encoded] body.
bytes_received: usize = 0,
_pending_cache_metadata: ?*CacheMetadata = null,
aborted: bool = false,
max_response_size: ?usize = null,
// We'll store the response header here
response_header: ?ResponseHead = null,
@@ -1065,7 +1288,7 @@ pub const Transfer = struct {
// as abort (doesn't send a notification, doesn't invoke an error callback)
fn kill(self: *Transfer) void {
if (self.req.shutdown_callback) |cb| {
cb(self.ctx);
cb(self.req.ctx);
}
if (self._performing or self.client.performing) {
@@ -1101,9 +1324,9 @@ pub const Transfer = struct {
});
if (execute_callback) {
self.req.error_callback(self.ctx, err);
self.req.error_callback(self.req.ctx, err);
} else if (self.req.shutdown_callback) |cb| {
cb(self.ctx);
cb(self.req.ctx);
}
}
@@ -1112,7 +1335,7 @@ pub const Transfer = struct {
const req = &self.req;
// Set callbacks and per-client settings on the pooled connection.
try conn.setCallbacks(Transfer.dataCallback);
try conn.setWriteCallback(Transfer.dataCallback);
try conn.setFollowLocation(false);
try conn.setProxy(client.http_proxy);
try conn.setTlsVerify(client.tls_verify, client.use_proxy);
@@ -1140,7 +1363,7 @@ pub const Transfer = struct {
try conn.setCookies(@ptrCast(cookies.ptr));
}
try conn.setPrivate(self);
conn.transport = .{ .http = self };
// add credentials
if (req.credentials) |creds| {
@@ -1340,11 +1563,9 @@ pub const Transfer = struct {
}
}
if (transfer.max_response_size) |max_size| {
if (transfer.getContentLength()) |cl| {
if (cl > max_size) {
return error.ResponseTooLarge;
}
if (transfer.getContentLength()) |cl| {
if (cl > transfer.client.max_response_size) {
return error.ResponseTooLarge;
}
}
@@ -1352,11 +1573,61 @@ pub const Transfer = struct {
.transfer = transfer,
});
const proceed = transfer.req.header_callback(transfer) catch |err| {
const proceed = transfer.req.header_callback(Response.fromTransfer(transfer)) catch |err| {
log.err(.http, "header_callback", .{ .err = err, .req = transfer });
return err;
};
if (transfer.client.network.cache != null and transfer.req.method == .GET) {
const rh = &transfer.response_header.?;
const allocator = transfer.arena.allocator();
const vary = if (conn.getResponseHeader("vary", 0)) |h| h.value else null;
const maybe_cm = try Cache.tryCache(
allocator,
std.time.timestamp(),
transfer.url,
rh.status,
rh.contentType(),
if (conn.getResponseHeader("cache-control", 0)) |h| h.value else null,
vary,
if (conn.getResponseHeader("age", 0)) |h| h.value else null,
conn.getResponseHeader("set-cookie", 0) != null,
conn.getResponseHeader("authorization", 0) != null,
);
if (maybe_cm) |cm| {
var iter = transfer.responseHeaderIterator();
var header_list = try iter.collect(allocator);
const end_of_response = header_list.items.len;
if (vary) |vary_str| {
var req_it = transfer.req.headers.iterator();
while (req_it.next()) |hdr| {
var vary_iter = std.mem.splitScalar(u8, vary_str, ',');
while (vary_iter.next()) |part| {
const name = std.mem.trim(u8, part, &std.ascii.whitespace);
if (std.ascii.eqlIgnoreCase(hdr.name, name)) {
try header_list.append(allocator, .{
.name = try allocator.dupe(u8, hdr.name),
.value = try allocator.dupe(u8, hdr.value),
});
}
}
}
}
const metadata = try transfer.arena.allocator().create(CacheMetadata);
metadata.* = cm;
metadata.headers = header_list.items[0..end_of_response];
metadata.vary_headers = header_list.items[end_of_response..];
transfer._pending_cache_metadata = metadata;
}
}
return proceed and transfer.aborted == false;
}
@@ -1367,10 +1638,7 @@ pub const Transfer = struct {
}
const conn: *http.Connection = @ptrCast(@alignCast(data));
var transfer = fromConnection(conn) catch |err| {
log.err(.http, "get private info", .{ .err = err, .source = "body callback" });
return http.writefunc_error;
};
var transfer = conn.transport.http;
if (!transfer._first_data_received) {
transfer._first_data_received = true;
@@ -1387,11 +1655,9 @@ pub const Transfer = struct {
// Pre-size buffer from Content-Length.
if (transfer.getContentLength()) |cl| {
if (transfer.max_response_size) |max_size| {
if (cl > max_size) {
transfer._callback_error = error.ResponseTooLarge;
return http.writefunc_error;
}
if (cl > transfer.client.max_response_size) {
transfer._callback_error = error.ResponseTooLarge;
return http.writefunc_error;
}
transfer._stream_buffer.ensureTotalCapacity(transfer.arena.allocator(), cl) catch {};
}
@@ -1400,11 +1666,9 @@ pub const Transfer = struct {
if (transfer._skip_body) return @intCast(chunk_len);
transfer.bytes_received += chunk_len;
if (transfer.max_response_size) |max_size| {
if (transfer.bytes_received > max_size) {
transfer._callback_error = error.ResponseTooLarge;
return http.writefunc_error;
}
if (transfer.bytes_received > transfer.client.max_response_size) {
transfer._callback_error = error.ResponseTooLarge;
return http.writefunc_error;
}
const chunk = buffer[0..chunk_len];
@@ -1433,11 +1697,6 @@ pub const Transfer = struct {
return .{ .list = .{ .list = self.response_header.?._injected_headers } };
}
fn fromConnection(conn: *const http.Connection) !*Transfer {
const private = try conn.getPrivate();
return @ptrCast(@alignCast(private));
}
pub fn fulfill(transfer: *Transfer, status: u16, headers: []const http.Header, body: ?[]const u8) !void {
if (transfer._conn != null) {
// should never happen, should have been intercepted/paused, and then
@@ -1455,7 +1714,7 @@ pub const Transfer = struct {
fn _fulfill(transfer: *Transfer, status: u16, headers: []const http.Header, body: ?[]const u8) !void {
const req = &transfer.req;
if (req.start_callback) |cb| {
try cb(transfer);
try cb(Response.fromTransfer(transfer));
}
transfer.response_header = .{
@@ -1474,13 +1733,13 @@ pub const Transfer = struct {
}
lp.assert(transfer._header_done_called == false, "Transfer.fulfill header_done_called", .{});
if (try req.header_callback(transfer) == false) {
if (try req.header_callback(Response.fromTransfer(transfer)) == false) {
transfer.abort(error.Abort);
return;
}
if (body) |b| {
try req.data_callback(transfer, b);
try req.data_callback(Response.fromTransfer(transfer), b);
}
try req.done_callback(req.ctx);
@@ -1517,10 +1776,10 @@ pub const Transfer = struct {
};
const Noop = struct {
fn headerCallback(_: *Transfer) !bool {
fn headerCallback(_: Response) !bool {
return true;
}
fn dataCallback(_: *Transfer, _: []const u8) !void {}
fn dataCallback(_: Response, _: []const u8) !void {}
fn doneCallback(_: *anyopaque) !void {}
fn errorCallback(_: *anyopaque, _: anyerror) void {}
};

View File

@@ -27,6 +27,9 @@ charset: [41]u8 = default_charset,
charset_len: usize = default_charset_len,
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.
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
const default_charset_len = 5;
@@ -61,7 +64,10 @@ pub const ContentType = union(ContentTypeEnum) {
image_webp: void,
application_json: 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 {
@@ -112,17 +118,18 @@ fn parseCharset(value: []const u8) error{ CharsetTooBig, Invalid }![]const u8 {
return value;
}
pub fn parse(input: []u8) !Mime {
pub fn parse(input: []const u8) !Mime {
if (input.len > 255) {
return error.TooBig;
}
// Zig's trim API is broken. The return type is always `[]const u8`,
// even if the input type is `[]u8`. @constCast is safe here.
var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace));
var buf: [255]u8 = undefined;
const normalized = std.ascii.lowerString(&buf, std.mem.trim(u8, input, &std.ascii.whitespace));
_ = 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) {
return .{ .content_type = content_type };
}
@@ -163,13 +170,12 @@ pub fn parse(input: []u8) !Mime {
}
}
return .{
.params = params,
.charset = charset,
.charset_len = charset_len,
.content_type = content_type,
.is_default_charset = !has_explicit_charset,
};
mime.params = params;
mime.charset = charset;
mime.charset_len = charset_len;
mime.content_type = content_type;
mime.is_default_charset = !has_explicit_charset;
return mime;
}
/// 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
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 type_name = trimRight(value[0..end]);
const attribute_start = end + 1;
@@ -444,10 +450,18 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
return error.Invalid;
}
return .{ .{ .other = .{
.type = main_type,
.sub_type = sub_type,
} }, attribute_start };
@memcpy(type_buf[0..main_type.len], main_type);
@memcpy(sub_type_buf[0..sub_type.len], sub_type);
return .{
.{
.other = .{
.type = type_buf[0..main_type.len],
.sub_type = sub_type_buf[0..sub_type.len],
},
},
attribute_start,
};
}
const VALID_CODEPOINTS = blk: {
@@ -461,6 +475,13 @@ const VALID_CODEPOINTS = blk: {
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 {
for (value) |b| {
if (VALID_CODEPOINTS[b] == false) {

View File

@@ -351,6 +351,30 @@ pub fn deinit(self: *Page, abort_http: bool) void {
session.releaseArena(qn.arena);
}
{
// Release all objects we're referencing
{
var it = self._blob_urls.valueIterator();
while (it.next()) |blob| {
blob.*.releaseRef(session);
}
}
{
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.releaseRef(session);
}
}
for (self._intersection_observers.items) |observer| {
observer.releaseRef(session);
}
self.window._document._selection.releaseRef(session);
}
session.browser.env.destroyContext(self.js);
self._script_manager.shutdown = true;
@@ -414,7 +438,15 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void {
pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
const current_origin = self.origin orelse return false;
return std.mem.startsWith(u8, url, current_origin);
// fastpath
if (!std.mem.startsWith(u8, url, current_origin)) {
return false;
}
// Starting here, at least protocols are equals.
// Compare hosts (domain:port) strictly
return std.mem.eql(u8, URL.getHost(url), URL.getHost(current_origin));
}
/// Look up a blob URL in this page's registry.
@@ -854,12 +886,10 @@ fn notifyParentLoadComplete(self: *Page) void {
parent.iframeCompletedLoading(self.iframe.?);
}
fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
fn pageHeaderDoneCallback(response: HttpClient.Response) !bool {
var self: *Page = @ptrCast(@alignCast(response.ctx));
const header = &transfer.response_header.?;
const response_url = std.mem.span(header.url);
const response_url = response.url();
if (std.mem.eql(u8, response_url, self.url) == false) {
// would be different than self.url in the case of a redirect
self.url = try self.arena.dupeZ(u8, response_url);
@@ -873,8 +903,8 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
if (comptime IS_DEBUG) {
log.debug(.page, "navigate header", .{
.url = self.url,
.status = header.status,
.content_type = header.contentType(),
.status = response.status(),
.content_type = response.contentType(),
.type = self._type,
});
}
@@ -895,14 +925,14 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
return true;
}
fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
fn pageDataCallback(response: HttpClient.Response, data: []const u8) !void {
var self: *Page = @ptrCast(@alignCast(response.ctx));
if (self._parse_state == .pre) {
// we lazily do this, because we might need the first chunk of data
// to sniff the content type
var mime: Mime = blk: {
if (transfer.response_header.?.contentType()) |ct| {
if (response.contentType()) |ct| {
break :blk try Mime.parse(ct);
}
break :blk Mime.sniff(data);
@@ -1338,20 +1368,24 @@ pub fn schedulePerformanceObserverDelivery(self: *Page) !void {
}
pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void {
observer.acquireRef();
self._mutation_observers.append(&observer.node);
}
pub fn unregisterMutationObserver(self: *Page, observer: *MutationObserver) void {
observer.releaseRef(self._session);
self._mutation_observers.remove(&observer.node);
}
pub fn registerIntersectionObserver(self: *Page, observer: *IntersectionObserver) !void {
observer.acquireRef();
try self._intersection_observers.append(self.arena, observer);
}
pub fn unregisterIntersectionObserver(self: *Page, observer: *IntersectionObserver) void {
for (self._intersection_observers.items, 0..) |obs, i| {
if (obs == observer) {
observer.releaseRef(self._session);
_ = self._intersection_observers.swapRemove(i);
return;
}
@@ -3588,3 +3622,41 @@ test "WebApi: Frames" {
test "WebApi: Integration" {
try testing.htmlRunner("integration", .{});
}
test "Page: isSameOrigin" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var page: Page = undefined;
page.origin = null;
try testing.expectEqual(false, page.isSameOrigin("https://origin.com/"));
page.origin = try URL.getOrigin(allocator, "https://origin.com/foo/bar") orelse unreachable;
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/foo/bar")); // exact same
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/bar/bar")); // path differ
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/")); // path differ
try testing.expectEqual(true, page.isSameOrigin("https://origin.com")); // no path
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/foo?q=1"));
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/foo#hash"));
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/foo?q=1#hash"));
// FIXME try testing.expectEqual(true, page.isSameOrigin("https://foo:bar@origin.com"));
// FIXME try testing.expectEqual(true, page.isSameOrigin("https://origin.com:443/foo"));
try testing.expectEqual(false, page.isSameOrigin("http://origin.com/")); // another proto
try testing.expectEqual(false, page.isSameOrigin("https://origin.com:123/")); // another port
try testing.expectEqual(false, page.isSameOrigin("https://sub.origin.com/")); // another subdomain
try testing.expectEqual(false, page.isSameOrigin("https://target.com/")); // different domain
try testing.expectEqual(false, page.isSameOrigin("https://origin.com.target.com/")); // different domain
try testing.expectEqual(false, page.isSameOrigin("https://target.com/@origin.com"));
page.origin = try URL.getOrigin(allocator, "https://origin.com:8443/foo") orelse unreachable;
try testing.expectEqual(true, page.isSameOrigin("https://origin.com:8443/bar"));
try testing.expectEqual(false, page.isSameOrigin("https://origin.com/bar")); // missing port
try testing.expectEqual(false, page.isSameOrigin("https://origin.com:9999/bar")); // wrong port
try testing.expectEqual(false, page.isSameOrigin(""));
try testing.expectEqual(false, page.isSameOrigin("not-a-url"));
try testing.expectEqual(false, page.isSameOrigin("//origin.com/foo"));
}

View File

@@ -68,7 +68,6 @@ pub fn waitCDP(self: *Runner, opts: WaitOpts) !CDPWaitResult {
fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult {
var timer = try std.time.Timer.start();
var ms_remaining = opts.ms;
const tick_opts = TickOpts{
.ms = 200,
@@ -92,11 +91,10 @@ fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult {
.cdp_socket => if (comptime is_cdp) return .cdp_socket else unreachable,
};
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
const ms_elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
if (ms_elapsed >= opts.ms) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
if (next_ms > 0) {
std.Thread.sleep(std.time.ns_per_ms * next_ms);
}
@@ -237,7 +235,16 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
page._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => return .done,
.raw_done => {
if (comptime is_cdp) {
const http_result = try http_client.tick(@intCast(opts.ms));
if (http_result == .cdp_socket) {
return .cdp_socket;
}
return .{ .ok = 0 };
}
return .done;
},
}
}

View File

@@ -22,7 +22,7 @@ const builtin = @import("builtin");
const log = @import("../log.zig");
const HttpClient = @import("HttpClient.zig");
const net_http = @import("../network/http.zig");
const http = @import("../network/http.zig");
const String = @import("../string.zig").String;
const js = @import("js/js.zig");
@@ -136,7 +136,7 @@ fn clearList(list: *std.DoublyLinkedList) void {
}
}
fn getHeaders(self: *ScriptManager) !net_http.Headers {
fn getHeaders(self: *ScriptManager) !http.Headers {
var headers = try self.client.newHeaders();
try self.page.headersForRequest(&headers);
return headers;
@@ -273,25 +273,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
// Let the outer errdefer handle releasing the arena if client.request fails
}
try self.client.request(.{
.url = url,
.ctx = script,
.method = .GET,
.frame_id = page._frame_id,
.headers = try self.getHeaders(),
.blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.resource_type = .script,
.notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
});
handover = true;
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
@@ -304,6 +285,32 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.stack = ls.local.stackTrace() catch "???",
});
}
{
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
try self.client.request(.{
.url = url,
.ctx = script,
.method = .GET,
.frame_id = page._frame_id,
.headers = try self.getHeaders(),
.blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.resource_type = .script,
.notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
});
}
handover = true;
}
if (is_blocking == false) {
@@ -694,82 +701,86 @@ pub const Script = struct {
self.manager.page.releaseArena(self.arena);
}
fn startCallback(transfer: *HttpClient.Transfer) !void {
log.debug(.http, "script fetch start", .{ .req = transfer });
fn startCallback(response: HttpClient.Response) !void {
log.debug(.http, "script fetch start", .{ .req = response });
}
fn headerCallback(transfer: *HttpClient.Transfer) !bool {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
const header = &transfer.response_header.?;
self.status = header.status;
if (header.status != 200) {
fn headerCallback(response: HttpClient.Response) !bool {
const self: *Script = @ptrCast(@alignCast(response.ctx));
self.status = response.status().?;
if (response.status() != 200) {
log.info(.http, "script header", .{
.req = transfer,
.status = header.status,
.content_type = header.contentType(),
.req = response,
.status = response.status(),
.content_type = response.contentType(),
});
return false;
}
if (comptime IS_DEBUG) {
log.debug(.http, "script header", .{
.req = transfer,
.status = header.status,
.content_type = header.contentType(),
.req = response,
.status = response.status(),
.content_type = response.contentType(),
});
}
{
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
.m = @tagName(std.meta.activeTag(self.mode)),
.a1 = self.debug_transfer_id,
.a2 = self.debug_transfer_tries,
.a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail,
.a7 = self.debug_transfer_intercept_state,
.a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.aborted,
.b4 = transfer.bytes_received,
.b5 = transfer._notified_fail,
.b7 = @intFromEnum(transfer._intercept_state),
.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.debug_transfer_tries = transfer._tries;
self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
switch (response.inner) {
.transfer => |transfer| {
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
.m = @tagName(std.meta.activeTag(self.mode)),
.a1 = self.debug_transfer_id,
.a2 = self.debug_transfer_tries,
.a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail,
.a7 = self.debug_transfer_intercept_state,
.a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.aborted,
.b4 = transfer.bytes_received,
.b5 = transfer._notified_fail,
.b7 = @intFromEnum(transfer._intercept_state),
.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.debug_transfer_tries = transfer._tries;
self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
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 });
var buffer: std.ArrayList(u8) = .empty;
if (transfer.getContentLength()) |cl| {
if (response.contentLength()) |cl| {
try buffer.ensureTotalCapacity(self.arena, cl);
}
self.source = .{ .remote = buffer };
return true;
}
fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
self._dataCallback(transfer, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
fn dataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(response.ctx));
self._dataCallback(response, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = response, .len = data.len });
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);
}

View File

@@ -501,7 +501,11 @@ pub const FinalizerCallback = struct {
session: *Session,
resolved_ptr_id: usize,
finalizer_ptr_id: usize,
_deinit: *const fn (ptr_id: usize, session: *Session) void,
release_ref: *const fn (ptr_id: usize, session: *Session) void,
// Track how many identities (JS worlds) reference this FC.
// Only cleanup when all identities have finalized.
identity_count: u8 = 0,
// For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one
// for every identity that gets the instance. In most cases, that'l be 1.
@@ -510,8 +514,9 @@ pub const FinalizerCallback = struct {
fc: *Session.FinalizerCallback,
};
// Called during page reset to force cleanup regardless of identity_count.
fn deinit(self: *FinalizerCallback, session: *Session) void {
self._deinit(self.finalizer_ptr_id, session);
self.release_ref(self.finalizer_ptr_id, session);
session.releaseArena(self.arena);
}
};

View File

@@ -25,28 +25,72 @@ const ResolveOpts = struct {
};
// path is anytype, so that it can be used with both []const u8 and [:0]const u8
pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 {
const PT = @TypeOf(path);
if (base.len == 0 or isCompleteHTTPUrl(path)) {
if (comptime opts.always_dupe or !isNullTerminated(PT)) {
const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts);
pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, comptime opts: ResolveOpts) ![:0]const u8 {
const PT = @TypeOf(source_path);
var path: [:0]const u8 = if (comptime !isNullTerminated(PT) or opts.always_dupe) try allocator.dupeZ(u8, source_path) else source_path;
if (base.len == 0) {
return processResolved(allocator, path, opts);
}
// Minimum is "x:" and skip relative path (very common case)
if (path.len >= 2 and path[0] != '/') {
if (std.mem.indexOfScalar(u8, path[0..], ':')) |scheme_path_end| {
scheme_check: {
const scheme_path = path[0..scheme_path_end];
//from "ws" to "https"
if (scheme_path_end >= 2 and scheme_path_end <= 5) {
const has_double_slashes: bool = scheme_path_end + 3 <= path.len and path[scheme_path_end + 1] == '/' and path[scheme_path_end + 2] == '/';
const special_schemes = [_][]const u8{ "https", "http", "ws", "wss", "file", "ftp" };
for (special_schemes) |special_scheme| {
if (std.ascii.eqlIgnoreCase(scheme_path, special_scheme)) {
const base_scheme_end = std.mem.indexOf(u8, base, "://") orelse 0;
if (base_scheme_end > 0 and std.mem.eql(u8, base[0..base_scheme_end], scheme_path) and !has_double_slashes) {
//Skip ":" and exit as relative state
path = path[scheme_path_end + 1 ..];
break :scheme_check;
} else {
var rest_start: usize = scheme_path_end + 1;
//Skip any slashas after "scheme:"
while (rest_start < path.len and (path[rest_start] == '/' or path[rest_start] == '\\')) {
rest_start += 1;
}
// A special scheme (exclude "file") must contain at least any chars after "://"
if (rest_start == path.len and !std.ascii.eqlIgnoreCase(scheme_path, "file")) {
return error.TypeError;
}
//File scheme allow empty host
const separator: []const u8 = if (!has_double_slashes and std.ascii.eqlIgnoreCase(scheme_path, "file")) ":///" else "://";
path = try std.mem.joinZ(allocator, "", &.{ scheme_path, separator, path[rest_start..] });
return processResolved(allocator, path, opts);
}
}
}
}
if (scheme_path.len > 0) {
for (scheme_path[1..]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '-' and c != '.') {
//Exit as relative state
break :scheme_check;
}
}
}
//path is complete http url
return processResolved(allocator, path, opts);
}
}
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
}
return path;
}
if (path.len == 0) {
if (comptime opts.always_dupe) {
const duped = try allocator.dupeZ(u8, base);
return processResolved(allocator, duped, opts);
if (opts.always_dupe) {
const dupe = try allocator.dupeZ(u8, base);
return processResolved(allocator, dupe, opts);
}
if (comptime opts.encode) {
return processResolved(allocator, base, opts);
}
return base;
return processResolved(allocator, base, opts);
}
if (path[0] == '?') {
@@ -63,14 +107,7 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
if (std.mem.startsWith(u8, path, "//")) {
// network-path reference
const index = std.mem.indexOfScalar(u8, base, ':') orelse {
if (comptime isNullTerminated(PT)) {
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
}
return path;
}
const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts);
return processResolved(allocator, path, opts);
};
const protocol = base[0 .. index + 1];
const result = try std.mem.joinZ(allocator, "", &.{ protocol, path });
@@ -96,6 +133,7 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
// trailing space so that we always have space to append the null terminator
// and so that we can compare the next two characters without needing to length check
var out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " });
const end = out.len - 2;
const path_marker = path_start + 1;
@@ -471,7 +509,7 @@ fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
return raw[authority_start .. auth.host_start - 1];
}
pub fn getHost(raw: [:0]const u8) []const u8 {
pub fn getHost(raw: []const u8) []const u8 {
const auth = parseAuthority(raw) orelse return "";
return auth.getHost(raw);
}
@@ -1570,3 +1608,182 @@ test "URL: getOrigin" {
}
}
}
test "URL: resolve path scheme" {
const Case = struct {
base: [:0]const u8,
path: [:0]const u8,
expected: [:0]const u8,
expected_error: bool = false,
};
const cases = [_]Case{
//same schemes and path as relative path (one slash)
.{
.base = "https://www.example.com/example",
.path = "https:/about",
.expected = "https://www.example.com/about",
},
//same schemes and path as relative path (without slash)
.{
.base = "https://www.example.com/example",
.path = "https:about",
.expected = "https://www.example.com/about",
},
//same schemes and path as absolute path (two slashes)
.{
.base = "https://www.example.com/example",
.path = "https://about",
.expected = "https://about",
},
//different schemes and path as absolute (without slash)
.{
.base = "https://www.example.com/example",
.path = "http:about",
.expected = "http://about",
},
//different schemes and path as absolute (with one slash)
.{
.base = "https://www.example.com/example",
.path = "http:/about",
.expected = "http://about",
},
//different schemes and path as absolute (with two slashes)
.{
.base = "https://www.example.com/example",
.path = "http://about",
.expected = "http://about",
},
//same schemes and path as absolute (with more slashes)
.{
.base = "https://site/",
.path = "https://path",
.expected = "https://path",
},
//path scheme is not special and path as absolute (without additional slashes)
.{
.base = "http://localhost/",
.path = "data:test",
.expected = "data:test",
},
//different schemes and path as absolute (pathscheme=ws)
.{
.base = "https://www.example.com/example",
.path = "ws://about",
.expected = "ws://about",
},
//different schemes and path as absolute (path scheme=wss)
.{
.base = "https://www.example.com/example",
.path = "wss://about",
.expected = "wss://about",
},
//different schemes and path as absolute (path scheme=ftp)
.{
.base = "https://www.example.com/example",
.path = "ftp://about",
.expected = "ftp://about",
},
//different schemes and path as absolute (path scheme=file)
.{
.base = "https://www.example.com/example",
.path = "file://path/to/file",
.expected = "file://path/to/file",
},
//different schemes and path as absolute (path scheme=file, host is empty)
.{
.base = "https://www.example.com/example",
.path = "file:/path/to/file",
.expected = "file:///path/to/file",
},
//different schemes and path as absolute (path scheme=file, host is empty)
.{
.base = "https://www.example.com/example",
.path = "file:/",
.expected = "file:///",
},
//different schemes without :// and normalize "file" scheme, absolute path
.{
.base = "https://www.example.com/example",
.path = "file:path/to/file",
.expected = "file:///path/to/file",
},
//same schemes without :// in path and rest starts with scheme:/, relative path
.{
.base = "https://www.example.com/example",
.path = "https:/file:/relative/path/",
.expected = "https://www.example.com/file:/relative/path/",
},
//same schemes without :// in path and rest starts with scheme://, relative path
.{
.base = "https://www.example.com/example",
.path = "https:/http://relative/path/",
.expected = "https://www.example.com/http://relative/path/",
},
//same schemes without :// in path , relative state
.{
.base = "http://www.example.com/example",
.path = "http:relative:path",
.expected = "http://www.example.com/relative:path",
},
//repeat different schemes in path
.{
.base = "http://www.example.com/example",
.path = "http:http:/relative/path/",
.expected = "http://www.example.com/http:/relative/path/",
},
//repeat different schemes in path
.{
.base = "http://www.example.com/example",
.path = "http:https://relative:path",
.expected = "http://www.example.com/https://relative:path",
},
//NOT required :// for blob scheme
.{
.base = "http://www.example.com/example",
.path = "blob:other",
.expected = "blob:other",
},
//NOT required :// for NON-special schemes and can contains "+" or "-" or "." in scheme
.{
.base = "http://www.example.com/example",
.path = "custom+foo:other",
.expected = "custom+foo:other",
},
//NOT required :// for NON-special schemes
.{
.base = "http://www.example.com/example",
.path = "blob:",
.expected = "blob:",
},
//NOT required :// for special scheme equal base scheme
.{
.base = "http://www.example.com/example",
.path = "http:",
.expected = "http://www.example.com/example",
},
//required :// for special scheme, so throw error.InvalidURL
.{
.base = "http://www.example.com/example",
.path = "https:",
.expected = "",
.expected_error = true,
},
//incorrect symbols in path scheme
.{
.base = "https://site",
.path = "http?://host/some",
.expected = "https://site/http?://host/some",
},
};
for (cases) |case| {
if (case.expected_error) {
const result = resolve(testing.arena_allocator, case.base, case.path, .{});
try testing.expectError(error.TypeError, result);
} else {
const result = try resolve(testing.arena_allocator, case.base, case.path, .{});
try testing.expectString(case.expected, result);
}
}
}

View File

@@ -296,7 +296,7 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context {
// it gets setup automatically as objects are created, but the Window
// object already exists in v8 (it's the global) so we manually create
// the mapping here.
const tao = try context_arena.create(@import("TaggedOpaque.zig"));
const tao = try params.identity_arena.create(@import("TaggedOpaque.zig"));
tao.* = .{
.value = @ptrCast(page.window),
.prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr,

View File

@@ -244,7 +244,10 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// The TAO contains the pointer to our Zig instance as
// well as any meta data we'll need to use it later.
// See the TaggedOpaque struct for more details.
const tao = try context_arena.create(TaggedOpaque);
// Use identity_arena so TAOs survive context destruction. V8 objects
// are stored in identity_map (session-level) and may be referenced
// after their creating context is destroyed (e.g., via microtasks).
const tao = try ctx.identity_arena.create(TaggedOpaque);
tao.* = .{
.value = resolved.ptr,
.prototype_chain = resolved.prototype_chain.ptr,
@@ -266,7 +269,6 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
v8.v8__Global__New(isolate.handle, js_obj.handle, gop.value_ptr);
if (resolved.finalizer) |finalizer| {
const finalizer_ptr_id = finalizer.ptr_id;
finalizer.acquireRef(finalizer_ptr_id);
const session = ctx.session;
const finalizer_gop = try session.finalizer_callbacks.getOrPut(session.page_arena, finalizer_ptr_id);
@@ -275,7 +277,8 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// see this Zig instance. We need to create the FinalizerCallback
// so that we can cleanup on page reset if v8 doesn't finalize.
errdefer _ = session.finalizer_callbacks.remove(finalizer_ptr_id);
finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.deinit);
finalizer.acquire_ref(finalizer_ptr_id);
finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.release_ref_from_zig);
}
const fc = finalizer_gop.value_ptr.*;
const identity_finalizer = try fc.arena.create(Session.FinalizerCallback.Identity);
@@ -283,8 +286,9 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
.fc = fc,
.identity = ctx.identity,
};
fc.identity_count += 1;
v8.v8__Global__SetWeakFinalizer(gop.value_ptr, identity_finalizer, finalizer.release, v8.kParameter);
v8.v8__Global__SetWeakFinalizer(gop.value_ptr, identity_finalizer, finalizer.release_ref, v8.kParameter);
}
return js_obj;
},
@@ -1128,9 +1132,9 @@ const Resolved = struct {
// Resolved.ptr is the most specific value in a chain (e.g. IFrame, not EventTarget, Node, ...)
// Finalizer.ptr_id is the most specific value in a chain that defines an acquireRef
ptr_id: usize,
deinit: *const fn (ptr_id: usize, session: *Session) void,
acquireRef: *const fn (ptr_id: usize) void,
release: *const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void,
acquire_ref: *const fn (ptr_id: usize) void,
release_ref: *const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void,
release_ref_from_zig: *const fn (ptr_id: usize, session: *Session) void,
};
};
pub fn resolveValue(value: anytype) Resolved {
@@ -1170,32 +1174,49 @@ fn resolveT(comptime T: type, value: *T) Resolved {
const finalizer_ptr = getFinalizerPtr(value);
const Wrap = struct {
fn deinit(ptr_id: usize, session: *Session) void {
FT.deinit(@ptrFromInt(ptr_id), session);
}
fn acquireRef(ptr_id: usize) void {
FT.acquireRef(@ptrFromInt(ptr_id));
}
fn release(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
fn releaseRef(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const identity_finalizer: *Session.FinalizerCallback.Identity = @ptrCast(@alignCast(ptr));
const fc = identity_finalizer.fc;
const session = fc.session;
const finalizer_ptr_id = fc.finalizer_ptr_id;
// Remove from this identity's map
if (identity_finalizer.identity.identity_map.fetchRemove(fc.resolved_ptr_id)) |kv| {
var global = kv.value;
v8.v8__Global__Reset(&global);
}
FT.releaseRef(@ptrFromInt(fc.finalizer_ptr_id), fc.session);
const identity_count = fc.identity_count;
if (identity_count == 1) {
// All IsolatedWorlds that reference this object have
// released it. Release the instance ref, remove the
// FinalizerCallback and free it.
FT.releaseRef(@ptrFromInt(finalizer_ptr_id), session);
const removed = session.finalizer_callbacks.remove(finalizer_ptr_id);
if (comptime IS_DEBUG) {
std.debug.assert(removed);
}
session.releaseArena(fc.arena);
} else {
fc.identity_count = identity_count - 1;
}
}
fn releaseRefFromZig(ptr_id: usize, session: *Session) void {
FT.releaseRef(@ptrFromInt(ptr_id), session);
}
};
break :blk .{
.ptr_id = @intFromPtr(finalizer_ptr),
.deinit = Wrap.deinit,
.acquireRef = Wrap.acquireRef,
.release = Wrap.release,
.acquire_ref = Wrap.acquireRef,
.release_ref = Wrap.releaseRef,
.release_ref_from_zig = Wrap.releaseRefFromZig,
};
},
};
@@ -1454,7 +1475,7 @@ fn createFinalizerCallback(
// The most specific value where finalizers are defined
// What actually gets acquired / released / deinit
finalizer_ptr_id: usize,
deinit: *const fn (ptr_id: usize, session: *Session) void,
release_ref: *const fn (ptr_id: usize, session: *Session) void,
) !*Session.FinalizerCallback {
const session = self.ctx.session;
@@ -1465,7 +1486,7 @@ fn createFinalizerCallback(
fc.* = .{
.arena = arena,
.session = session,
._deinit = deinit,
.release_ref = release_ref,
.resolved_ptr_id = resolved_ptr_id,
.finalizer_ptr_id = finalizer_ptr_id,
};

View File

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

View File

@@ -25,9 +25,7 @@ const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
pub const Opts = struct {
// Options for future customization (e.g., dialect)
};
pub const Opts = struct {};
const State = struct {
const ListType = enum { ordered, unordered };
@@ -39,7 +37,6 @@ const State = struct {
list_depth: usize = 0,
list_stack: [32]ListState = undefined,
pre_node: ?*Node = null,
in_code: bool = false,
in_table: bool = false,
table_row_index: usize = 0,
table_col_count: usize = 0,
@@ -100,27 +97,35 @@ fn getAnchorLabel(el: *Element) ?[]const u8 {
return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title"));
}
fn hasBlockDescendant(root: *Node) bool {
var tw = TreeWalker.FullExcludeSelf.Elements.init(root, .{});
while (tw.next()) |el| {
if (el.getTag().isBlock()) return true;
}
return false;
}
const ContentInfo = struct {
has_visible: bool,
has_block: bool,
};
fn hasVisibleContent(root: *Node) bool {
fn analyzeContent(root: *Node) ContentInfo {
var result: ContentInfo = .{ .has_visible = false, .has_block = false };
var tw = TreeWalker.FullExcludeSelf.init(root, .{});
while (tw.next()) |node| {
if (isSignificantText(node)) return true;
if (node.is(Element)) |el| {
if (isSignificantText(node)) {
result.has_visible = true;
if (result.has_block) return result;
} else if (node.is(Element)) |el| {
if (!isVisibleElement(el)) {
tw.skipChildren();
} else if (el.getTag() == .img) {
return true;
} else {
const tag = el.getTag();
if (tag == .img) {
result.has_visible = true;
if (result.has_block) return result;
}
if (tag.isBlock()) {
result.has_block = true;
if (result.has_visible) return result;
}
}
}
}
return false;
return result;
}
const Context = struct {
@@ -170,9 +175,7 @@ const Context = struct {
if (!isVisibleElement(el)) return;
// --- Opening Tag Logic ---
// Ensure block elements start on a new line (double newline for paragraphs etc)
// Ensure block elements start on a new line
if (tag.isBlock() and !self.state.in_table) {
try self.ensureNewline();
if (shouldAddSpacing(tag)) {
@@ -182,7 +185,6 @@ const Context = struct {
try self.ensureNewline();
}
// Prefixes
switch (tag) {
.h1 => try self.writer.writeAll("# "),
.h2 => try self.writer.writeAll("## "),
@@ -225,7 +227,6 @@ const Context = struct {
try self.writer.writeByte('|');
},
.td, .th => {
// Note: leading pipe handled by previous cell closing or tr opening
self.state.last_char_was_newline = false;
try self.writer.writeByte(' ');
},
@@ -241,7 +242,6 @@ const Context = struct {
.code => {
if (self.state.pre_node == null) {
try self.writer.writeByte('`');
self.state.in_code = true;
self.state.last_char_was_newline = false;
}
},
@@ -286,16 +286,15 @@ const Context = struct {
return;
},
.anchor => {
const has_content = hasVisibleContent(el.asNode());
const info = analyzeContent(el.asNode());
const label = getAnchorLabel(el);
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
if (!has_content and label == null and href_raw == null) return;
if (!info.has_visible and label == null and href_raw == null) return;
const has_block = hasBlockDescendant(el.asNode());
const href = if (href_raw) |h| URL.resolve(self.page.call_arena, self.page.base(), h, .{ .encode = true }) catch h else null;
if (has_block) {
if (info.has_block) {
try self.renderChildren(el.asNode());
if (href) |h| {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
@@ -307,25 +306,12 @@ const Context = struct {
return;
}
if (isStandaloneAnchor(el)) {
const standalone = isStandaloneAnchor(el);
if (standalone) {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
try self.writer.writeByte('[');
if (has_content) {
try self.renderChildren(el.asNode());
} else {
try self.writer.writeAll(label orelse "");
}
try self.writer.writeAll("](");
if (href) |h| {
try self.writer.writeAll(h);
}
try self.writer.writeAll(")\n");
self.state.last_char_was_newline = true;
return;
}
try self.writer.writeByte('[');
if (has_content) {
if (info.has_visible) {
try self.renderChildren(el.asNode());
} else {
try self.writer.writeAll(label orelse "");
@@ -335,7 +321,12 @@ const Context = struct {
try self.writer.writeAll(h);
}
try self.writer.writeByte(')');
self.state.last_char_was_newline = false;
if (standalone) {
try self.writer.writeByte('\n');
self.state.last_char_was_newline = true;
} else {
self.state.last_char_was_newline = false;
}
return;
},
.input => {
@@ -350,12 +341,8 @@ const Context = struct {
else => {},
}
// --- Render Children ---
try self.renderChildren(el.asNode());
// --- Closing Tag Logic ---
// Suffixes
switch (tag) {
.pre => {
if (!self.state.last_char_was_newline) {
@@ -368,7 +355,6 @@ const Context = struct {
.code => {
if (self.state.pre_node == null) {
try self.writer.writeByte('`');
self.state.in_code = false;
self.state.last_char_was_newline = false;
}
},
@@ -411,7 +397,6 @@ const Context = struct {
else => {},
}
// Post-block newlines
if (tag.isBlock() and !self.state.in_table) {
try self.ensureNewline();
}
@@ -454,15 +439,19 @@ const Context = struct {
}
fn escape(self: *Context, text: []const u8) !void {
for (text) |c| {
var start: usize = 0;
for (text, 0..) |c, i| {
switch (c) {
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
if (i > start) try self.writer.writeAll(text[start..i]);
try self.writer.writeByte('\\');
try self.writer.writeByte(c);
start = i + 1;
},
else => try self.writer.writeByte(c),
else => {},
}
}
if (start < text.len) try self.writer.writeAll(text[start..]);
}
};

View File

@@ -0,0 +1,563 @@
<!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>
<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_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 - error fires before close
testing.expectEqual(['error', '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>
<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>
<script id=receive_binary_as_blob type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
// binaryType defaults to 'blob'
ws.addEventListener('open', () => {
// Send binary data - server will echo with 0xEE marker
const data = new Uint8Array([1, 2, 3, 4, 5]);
ws.send(data);
});
ws.addEventListener('message', async (e) => {
// e.data should be a Blob
received.push(e.data instanceof Blob);
received.push(e.data.size);
// Read the Blob using FileReader
const reader = new FileReader();
reader.onload = () => {
const arr = new Uint8Array(reader.result);
received.push(arr[0]); // 0xEE marker
received.push(arr[1]); // Our first byte
ws.close();
};
reader.readAsArrayBuffer(e.data);
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([true, 6, 0xEE, 1], received);
});
}
</script>
<script id=url_with_fragment_rejected>
{
testing.expectError('SyntaxError', () => {
new WebSocket('ws://127.0.0.1:9584/#fragment');
});
}
</script>

View File

@@ -4,7 +4,7 @@
<div id=empty></div>
<div id=one><p id=p10></p></div>
<script id=childNodes>
<!--<script id=childNodes>
const div = $('#d1');
const children = div.childNodes;
testing.expectEqual(true, children instanceof NodeList);
@@ -65,24 +65,24 @@
testing.expectEqual([], Array.from(empty.values()));
testing.expectEqual([], Array.from(empty.entries()));
testing.expectEqual([], Array.from(empty));
</script>
</script> -->
<script id=one>
const one = $('#one').childNodes;
const p10 = $('#p10');
testing.expectEqual(1, one.length);
testing.expectEqual(p10, one[0]);
testing.expectEqual([0], Array.from(one.keys()));
testing.expectEqual([p10], Array.from(one.values()));
testing.expectEqual([[0, p10]], Array.from(one.entries()));
// const p10 = $('#p10');
// testing.expectEqual(1, one.length);
// testing.expectEqual(p10, one[0]);
// testing.expectEqual([0], Array.from(one.keys()));
// testing.expectEqual([p10], Array.from(one.values()));
// testing.expectEqual([[0, p10]], Array.from(one.entries()));
testing.expectEqual([p10], Array.from(one));
// testing.expectEqual([p10], Array.from(one));
let foreach = [];
one.forEach((p) => foreach.push(p));
testing.expectEqual([p10], foreach);
</script>
<script id=contains>
<!-- <script id=contains>
testing.expectEqual(true, document.contains(document));
testing.expectEqual(true, $('#d1').contains($('#d1')));
testing.expectEqual(true, document.contains($('#d1')));
@@ -94,3 +94,4 @@
testing.expectEqual(false, $('#d1').contains($('#empty')));
testing.expectEqual(false, $('#d1').contains($('#p10')));
</script>
-->

View File

@@ -523,6 +523,31 @@ pub fn setDir(self: *Element, value: []const u8, page: *Page) !void {
return self.setAttributeSafe(comptime .wrap("dir"), .wrap(value), page);
}
// ARIAMixin - ARIA attribute reflection
pub fn getAriaAtomic(self: *const Element) ?[]const u8 {
return self.getAttributeSafe(comptime .wrap("aria-atomic"));
}
pub fn setAriaAtomic(self: *Element, value: ?[]const u8, page: *Page) !void {
if (value) |v| {
try self.setAttributeSafe(comptime .wrap("aria-atomic"), .wrap(v), page);
} else {
try self.removeAttribute(comptime .wrap("aria-atomic"), page);
}
}
pub fn getAriaLive(self: *const Element) ?[]const u8 {
return self.getAttributeSafe(comptime .wrap("aria-live"));
}
pub fn setAriaLive(self: *Element, value: ?[]const u8, page: *Page) !void {
if (value) |v| {
try self.setAttributeSafe(comptime .wrap("aria-live"), .wrap(v), page);
} else {
try self.removeAttribute(comptime .wrap("aria-live"), page);
}
}
pub fn getClassName(self: *const Element) []const u8 {
return self.getAttributeSafe(comptime .wrap("class")) orelse "";
}
@@ -1686,6 +1711,8 @@ pub const JsApi = struct {
pub const localName = bridge.accessor(Element.getLocalName, null, .{});
pub const id = bridge.accessor(Element.getId, Element.setId, .{});
pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{});
pub const ariaAtomic = bridge.accessor(Element.getAriaAtomic, Element.setAriaAtomic, .{});
pub const ariaLive = bridge.accessor(Element.getAriaLive, Element.setAriaLive, .{});
pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{});
pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{});
pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{});

View File

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

View File

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

View File

@@ -114,7 +114,9 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
pub fn deinit(self: *IntersectionObserver, session: *Session) void {
self._callback.release();
for (self._pending_entries.items) |entry| {
entry.deinitIfUnused(session);
// These were never handed to v8, they do not have a corresponding
// FinalizerCallback. We 100% own them.
entry.deinit(session);
}
session.releaseArena(self._arena);
}
@@ -135,14 +137,11 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void
}
}
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
self._rc._refs += 1;
try self._observing.append(self._arena, target);
if (self._observing.items.len == 1) {
try page.registerIntersectionObserver(self);
}
try self._observing.append(self._arena, target);
// Don't initialize previous state yet - let checkIntersection do it
// This ensures we get an entry on first observation
@@ -166,7 +165,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
while (j < self._pending_entries.items.len) {
if (self._pending_entries.items[j]._target == target) {
const entry = self._pending_entries.swapRemove(j);
entry.deinitIfUnused(page._session);
entry.deinit(page._session);
} else {
j += 1;
}
@@ -176,25 +175,21 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
}
if (original_length > 0 and self._observing.items.len == 0) {
self._rc._refs -= 1;
page.unregisterIntersectionObserver(self);
}
}
pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
for (self._pending_entries.items) |entry| {
entry.deinitIfUnused(page._session);
entry.deinit(page._session);
}
self._pending_entries.clearRetainingCapacity();
self._previous_states.clearRetainingCapacity();
const observing_count = self._observing.items.len;
self._observing.clearRetainingCapacity();
page.unregisterIntersectionObserver(self);
if (observing_count > 0) {
_ = self.releaseRef(page._session);
if (self._observing.items.len > 0) {
page.unregisterIntersectionObserver(self);
}
self._observing.clearRetainingCapacity();
}
pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
@@ -340,13 +335,6 @@ pub const IntersectionObserverEntry = struct {
session.releaseArena(self._arena);
}
fn deinitIfUnused(self: *IntersectionObserverEntry, session: *Session) void {
if (self._rc._refs == 0) {
// hasn't been handed to JS yet.
self.deinit(session);
}
}
pub fn releaseRef(self: *IntersectionObserverEntry, session: *Session) void {
self._rc.release(self, session);
}

View File

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

View File

@@ -87,8 +87,12 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
return self;
}
/// Force cleanup on Session shutdown.
pub fn deinit(self: *MutationObserver, session: *Session) void {
for (self._pending_records.items) |record| {
// These were never handed to v8, they do not have a corresponding
// FinalizerCallback. We 100% own them.
record.deinit(session);
}
self._callback.release();
session.releaseArena(self._arena);
}
@@ -163,16 +167,14 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
}
}
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
self._rc._refs += 1;
try page.registerMutationObserver(self);
}
try self._observing.append(arena, .{
.target = target,
.options = store_options,
});
if (self._observing.items.len == 1) {
try page.registerMutationObserver(self);
}
}
pub fn disconnect(self: *MutationObserver, page: *Page) void {
@@ -180,13 +182,11 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void {
_ = record.releaseRef(page._session);
}
self._pending_records.clearRetainingCapacity();
const observing_count = self._observing.items.len;
self._observing.clearRetainingCapacity();
if (observing_count > 0) {
_ = self.releaseRef(page._session);
if (self._observing.items.len > 0) {
page.unregisterMutationObserver(self);
}
page.unregisterMutationObserver(self);
self._observing.clearRetainingCapacity();
}
pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {

View File

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

View File

@@ -42,8 +42,8 @@ _rc: lp.RC(u32) = .{},
pub fn deinit(self: *NodeList, session: *Session) void {
switch (self._data) {
.selector_list => |list| list.deinit(session),
.child_nodes => |cn| cn.deinit(session),
.selector_list => |list| list.deinit(session),
else => {},
}
}
@@ -92,7 +92,12 @@ pub fn entries(self: *NodeList, page: *Page) !*EntryIterator {
pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void {
var i: i32 = 0;
var it = try self.values(page);
// the iterator takes a reference against our list
defer self.releaseRef(page._session);
while (true) : (i += 1) {
const next = try it.next(page);
if (next.done) {

View File

@@ -26,7 +26,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
const R = reflect(Inner, field);
return struct {
inner: Inner,
_inner: Inner,
_rc: lp.RC(u8) = .{},
const Self = @This();
@@ -38,29 +39,31 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
};
pub fn init(inner: Inner, page: *Page) !*Self {
return page._factory.create(Self{ .inner = inner });
const self = try page._factory.create(Self{ ._inner = inner });
if (@hasDecl(Inner, "acquireRef")) {
self._inner.acquireRef();
}
return self;
}
pub fn deinit(self: *Self, session: *Session) void {
_ = self;
_ = session;
if (@hasDecl(Inner, "releaseRef")) {
self._inner.releaseRef(session);
}
session.factory.destroy(self);
}
pub fn releaseRef(self: *Self, session: *Session) void {
// Release the reference to the inner type that we acquired
if (@hasDecl(Inner, "releaseRef")) {
self.inner.releaseRef(session);
}
self._rc.release(self, session);
}
pub fn acquireRef(self: *Self) void {
if (@hasDecl(Inner, "acquireRef")) {
self.inner.acquireRef();
}
self._rc.acquire();
}
pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result {
const entry = (if (comptime R.has_error_return) try self.inner.next(page) else self.inner.next(page)) orelse {
const entry = (if (comptime R.has_error_return) try self._inner.next(page) else self._inner.next(page)) orelse {
return .{ .done = true, .value = null };
};

View File

@@ -391,6 +391,14 @@ pub fn setLang(self: *HtmlElement, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("lang"), .wrap(value), page);
}
pub fn getTitle(self: *HtmlElement) []const u8 {
return self.asElement().getAttributeSafe(comptime .wrap("title")) orelse "";
}
pub fn setTitle(self: *HtmlElement, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("title"), .wrap(value), page);
}
pub fn getAttributeFunction(
self: *HtmlElement,
listener_type: GlobalEventHandler,
@@ -1231,6 +1239,7 @@ pub const JsApi = struct {
pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{});
pub const lang = bridge.accessor(HtmlElement.getLang, HtmlElement.setLang, .{});
pub const tabIndex = bridge.accessor(HtmlElement.getTabIndex, HtmlElement.setTabIndex, .{});
pub const title = bridge.accessor(HtmlElement.getTitle, HtmlElement.setTitle, .{});
pub const onabort = bridge.accessor(HtmlElement.getOnAbort, HtmlElement.setOnAbort, .{});
pub const onanimationcancel = bridge.accessor(HtmlElement.getOnAnimationCancel, HtmlElement.setOnAnimationCancel, .{});

View File

@@ -174,6 +174,14 @@ pub fn setType(self: *Anchor, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("type"), .wrap(value), page);
}
pub fn getRel(self: *Anchor) []const u8 {
return self.asConstElement().getAttributeSafe(comptime .wrap("rel")) orelse "";
}
pub fn setRel(self: *Anchor, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("rel"), .wrap(value), page);
}
pub fn getName(self: *const Anchor) []const u8 {
return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse "";
}
@@ -218,6 +226,7 @@ pub const JsApi = struct {
pub const pathname = bridge.accessor(Anchor.getPathname, Anchor.setPathname, .{});
pub const search = bridge.accessor(Anchor.getSearch, Anchor.setSearch, .{});
pub const hash = bridge.accessor(Anchor.getHash, Anchor.setHash, .{});
pub const rel = bridge.accessor(Anchor.getRel, Anchor.setRel, .{});
pub const @"type" = bridge.accessor(Anchor.getType, Anchor.setType, .{});
pub const text = bridge.accessor(Anchor.getText, Anchor.setText, .{});
pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true });

View File

@@ -0,0 +1,102 @@
// 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,16 +30,23 @@ const Allocator = std.mem.Allocator;
const MessageEvent = @This();
_proto: *Event,
_data: ?js.Value.Temp = null,
_data: ?Data = null,
_origin: []const u8 = "",
_source: ?*Window = null,
const MessageEventOptions = struct {
data: ?js.Value.Temp = null,
data: ?Data = null,
origin: ?[]const u8 = null,
source: ?*Window = null,
};
pub const Data = union(enum) {
value: js.Value.Temp,
string: []const u8,
arraybuffer: js.ArrayBuffer,
blob: *@import("../Blob.zig"),
};
const Options = Event.inheritOptions(MessageEvent, MessageEventOptions);
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent {
@@ -75,7 +82,11 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool
pub fn deinit(self: *MessageEvent, session: *Session) void {
if (self._data) |d| {
d.release();
switch (d) {
.value => |js_val| js_val.release(),
.blob => |blob| blob.releaseRef(session),
.string, .arraybuffer => {},
}
}
self._proto.deinit(session);
}
@@ -92,7 +103,7 @@ pub fn asEvent(self: *MessageEvent) *Event {
return self._proto;
}
pub fn getData(self: *const MessageEvent) ?js.Value.Temp {
pub fn getData(self: *const MessageEvent) ?Data {
return self._data;
}

View File

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

View File

@@ -86,8 +86,8 @@ pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void {
}
// TODO: do we really need 2 different header structs??
const net_http = @import("../../../network/http.zig");
pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *net_http.Headers) !void {
const http = @import("../../../network/http.zig");
pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *http.Headers) !void {
for (self._list._entries.items) |entry| {
const merged = try std.mem.concatWithSentinel(allocator, u8, &.{ entry.name.str(), ": ", entry.value.str() }, 0);
try http_headers.add(merged);

View File

@@ -19,7 +19,7 @@
const std = @import("std");
const js = @import("../../js/js.zig");
const net_http = @import("../../../network/http.zig");
const http = @import("../../../network/http.zig");
const URL = @import("../URL.zig");
const Page = @import("../../Page.zig");
@@ -31,7 +31,7 @@ const Allocator = std.mem.Allocator;
const Request = @This();
_url: [:0]const u8,
_method: net_http.Method,
_method: http.Method,
_headers: ?*Headers,
_body: ?[]const u8,
_arena: Allocator,
@@ -119,14 +119,14 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
});
}
fn parseMethod(method: []const u8, page: *Page) !net_http.Method {
fn parseMethod(method: []const u8, page: *Page) !http.Method {
if (method.len > "propfind".len) {
return error.InvalidMethod;
}
const lower = std.ascii.lowerString(&page.buf, method);
const method_lookup = std.StaticStringMap(net_http.Method).initComptime(.{
const method_lookup = std.StaticStringMap(http.Method).initComptime(.{
.{ "get", .GET },
.{ "post", .POST },
.{ "delete", .DELETE },

View File

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

View File

@@ -0,0 +1,727 @@
// 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;
}
// Fragments are not allowed in WebSocket URLs
if (std.mem.indexOfScalar(u8, url, '#') != null) {
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.newConnection() orelse {
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(receivedHeaderCallback);
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(.websocket, "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(.websocket, "disconnected", .{ .err = err, .url = self._url });
} else {
log.info(.websocket, "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 "";
// Spec requires error event before close on abnormal closure
if (!was_clean) {
self.dispatchErrorEvent() catch |err| {
log.err(.websocket, "error event dispatch failed", .{ .err = err });
};
}
self.dispatchCloseEvent(code, reason, was_clean) catch |err| {
log.err(.websocket, "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);
self._send_queue.clearRetainingCapacity();
}
}
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;
}
// Validate close code per spec: must be 1000 or in range 3000-4999
if (code_) |code| {
if (code != 1000 and (code < 3000 or code > 4999)) {
return error.InvalidAccessError;
}
}
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 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(comptime .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)
switch (self._binary_type) {
.arraybuffer => .{ .arraybuffer = .{ .values = data } },
.blob => blk: {
const blob = try Blob.init(&.{data}, .{}, page);
blob.acquireRef();
break :blk .{ .blob = blob };
},
}
else
.{ .string = data };
const event = try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = msg_data,
.origin = "",
}, page);
try page._event_manager.dispatchDirect(target, event.asEvent(), self._on_message, .{ .context = "WebSocket message" });
}
}
fn dispatchErrorEvent(self: *WebSocket) !void {
const page = self._page;
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "error", self._on_error)) {
const event = try Event.initTrusted(comptime .wrap("error"), .{}, page);
try page._event_manager.dispatchDirect(target, event, self._on_error, .{ .context = "WebSocket error" });
}
}
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(comptime .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(.websocket, "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
if (comptime IS_DEBUG) {
log.debug(.websocket, "send start", .{ .url = self._url, .len = byte_msg.data.len });
}
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);
if (comptime IS_DEBUG) {
log.debug(.websocket, "send complete", .{ .url = self._url, .len = byte_msg.data.len, .queue = self._send_queue.items.len });
}
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(.websocket, "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(.websocket, "missing meta", .{ .url = self._url });
return error.NoFrameMeta;
};
if (meta.offset == 0) {
if (comptime IS_DEBUG) {
log.debug(.websocket, "incoming message", .{ .url = self._url, .len = meta.len, .bytes_left = meta.bytes_left, .type = meta.frame_type });
}
// 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
const received_code = if (message.len >= 2)
@as(u16, message[0]) << 8 | message[1]
else
1005; // No status code received
if (self._ready_state == .closing) {
// Client-initiated close: this is the server's response.
// Close handshake complete - disconnect.
self.disconnected(null);
} else {
// Server-initiated close: send reciprocal close frame per RFC 6455 §5.5.1
self._close_code = received_code;
if (message.len > 2) {
self._close_reason = try self._arena.dupe(u8, message[2..]);
}
self._ready_state = .closing;
try self.queueMessage(.close);
}
},
.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 receivedHeaderCallback(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(.websocket, "connected", .{ .url = self._url });
self.dispatchOpenEvent() catch |err| {
log.err(.websocket, "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" {
try testing.htmlRunner("net/websocket.html", .{});
}

View File

@@ -22,7 +22,7 @@ const js = @import("../../js/js.zig");
const log = @import("../../../log.zig");
const HttpClient = @import("../../HttpClient.zig");
const net_http = @import("../../../network/http.zig");
const http = @import("../../../network/http.zig");
const URL = @import("../../URL.zig");
const Mime = @import("../../Mime.zig");
@@ -43,11 +43,11 @@ _rc: lp.RC(u8) = .{},
_page: *Page,
_proto: *XMLHttpRequestEventTarget,
_arena: Allocator,
_transfer: ?*HttpClient.Transfer = null,
_http_response: ?HttpClient.Response = null,
_active_request: bool = false,
_url: [:0]const u8 = "",
_method: net_http.Method = .GET,
_method: http.Method = .GET,
_request_headers: *Headers,
_request_body: ?[]const u8 = null,
@@ -90,19 +90,19 @@ const ResponseType = enum {
pub fn init(page: *Page) !*XMLHttpRequest {
const arena = try page.getArena(.{ .debug = "XMLHttpRequest" });
errdefer page.releaseArena(arena);
const xhr = try page._factory.xhrEventTarget(arena, XMLHttpRequest{
const self = try page._factory.xhrEventTarget(arena, XMLHttpRequest{
._page = page,
._arena = arena,
._proto = undefined,
._request_headers = try Headers.init(null, page),
});
return xhr;
return self;
}
pub fn deinit(self: *XMLHttpRequest, session: *Session) void {
if (self._transfer) |transfer| {
transfer.abort(error.Abort);
self._transfer = null;
if (self._http_response) |resp| {
resp.abort(error.Abort);
self._http_response = null;
}
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
pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void {
// Abort any in-progress request
if (self._transfer) |transfer| {
if (self._http_response) |transfer| {
transfer.abort(error.Abort);
self._transfer = null;
self._http_response = null;
}
// Reset internal state
@@ -243,7 +243,10 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
try page.headersForRequest(&headers);
}
try http_client.request(.{
self.acquireRef();
self._active_request = true;
http_client.request(.{
.ctx = self,
.url = self._url,
.method = self._method,
@@ -260,9 +263,10 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
.done_callback = httpDoneCallback,
.error_callback = httpErrorCallback,
.shutdown_callback = httpShutdownCallback,
});
self.acquireRef();
self._active_request = true;
}) catch |err| {
self.releaseSelfRef();
return err;
};
}
fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void {
@@ -398,34 +402,32 @@ pub fn getResponseXML(self: *XMLHttpRequest, page: *Page) !?*Node.Document {
};
}
fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx));
fn httpStartCallback(response: HttpClient.Response) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
if (comptime IS_DEBUG) {
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: net_http.Header) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx));
fn httpHeaderCallback(response: HttpClient.Response, header: http.Header) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value });
try self._response_headers.append(self._arena, joined);
}
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx));
const header = &transfer.response_header.?;
fn httpHeaderDoneCallback(response: HttpClient.Response) !bool {
const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
if (comptime IS_DEBUG) {
log.debug(.http, "request header", .{
.source = "xhr",
.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| {
log.info(.http, "invalid content type", .{
.content_Type = ct,
@@ -436,18 +438,18 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
};
}
var it = transfer.responseHeaderIterator();
var it = response.headerIterator();
while (it.next()) |hdr| {
const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value });
try self._response_headers.append(self._arena, joined);
}
self._response_status = header.status;
if (transfer.getContentLength()) |cl| {
self._response_status = response.status().?;
if (response.contentLength()) |cl| {
self._response_len = 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;
@@ -462,8 +464,8 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
return true;
}
fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx));
fn httpDataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
try self._response_data.appendSlice(self._arena, data);
const page = self._page;
@@ -486,7 +488,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
// Not that the request is done, the http/client will free the transfer
// object. It isn't safe to keep it around.
self._transfer = null;
self._http_response = null;
const page = self._page;
@@ -509,22 +511,23 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx));
// http client will close it after an error, it isn't safe to keep around
self.handleError(err);
if (self._transfer != null) {
self._transfer = null;
if (self._http_response != null) {
self._http_response = null;
}
self.releaseSelfRef();
}
fn httpShutdownCallback(ctx: *anyopaque) void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx));
self._transfer = null;
self._http_response = null;
self.releaseSelfRef();
}
pub fn abort(self: *XMLHttpRequest) void {
self.handleError(error.Abort);
if (self._transfer) |transfer| {
self._transfer = null;
transfer.abort(error.Abort);
if (self._http_response) |resp| {
self._http_response = null;
resp.abort(error.Abort);
}
self.releaseSelfRef();
}
@@ -574,7 +577,7 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, page: *Page) !void {
}
}
fn parseMethod(method: []const u8) !net_http.Method {
fn parseMethod(method: []const u8) !http.Method {
if (std.ascii.eqlIgnoreCase(method, "get")) {
return .GET;
}

View File

@@ -23,7 +23,7 @@ const CDP = @import("../CDP.zig");
const log = @import("../../log.zig");
const HttpClient = @import("../../browser/HttpClient.zig");
const net_http = @import("../../network/http.zig");
const http = @import("../../network/http.zig");
const Notification = @import("../../Notification.zig");
const network = @import("network.zig");
@@ -224,7 +224,7 @@ fn continueRequest(cmd: *CDP.Command) !void {
url: ?[]const u8 = null,
method: ?[]const u8 = null,
postData: ?[]const u8 = null,
headers: ?[]const net_http.Header = null,
headers: ?[]const http.Header = null,
interceptResponse: bool = false,
})) orelse return error.InvalidParams;
@@ -249,7 +249,7 @@ fn continueRequest(cmd: *CDP.Command) !void {
try transfer.updateURL(try arena.dupeZ(u8, url));
}
if (params.method) |method| {
transfer.req.method = std.meta.stringToEnum(net_http.Method, method) orelse return error.InvalidParams;
transfer.req.method = std.meta.stringToEnum(http.Method, method) orelse return error.InvalidParams;
}
if (params.headers) |headers| {
@@ -326,7 +326,7 @@ fn fulfillRequest(cmd: *CDP.Command) !void {
const params = (try cmd.params(struct {
requestId: []const u8, // "INT-{d}"
responseCode: u16,
responseHeaders: ?[]const net_http.Header = null,
responseHeaders: ?[]const http.Header = null,
binaryResponseHeaders: ?[]const u8 = null,
body: ?[]const u8 = null,
responsePhrase: ?[]const u8 = null,

View File

@@ -86,7 +86,7 @@ fn report(reason: []const u8, begin_addr: usize, args: anytype) !void {
var url_buffer: [4096]u8 = undefined;
const url = blk: {
var writer: std.Io.Writer = .fixed(&url_buffer);
try writer.print("https://crash.lightpanda.io/c?v={s}&r=", .{lp.build_config.version});
try writer.print("https://crash.lightpanda.io/c?v={s}&r=", .{lp.build_config.version_encoded});
for (reason) |b| {
switch (b) {
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_' => try writer.writeByte(b),

View File

@@ -18,7 +18,7 @@
const std = @import("std");
pub const App = @import("App.zig");
pub const Network = @import("network/Runtime.zig");
pub const Network = @import("network/Network.zig");
pub const Server = @import("Server.zig");
pub const Config = @import("Config.zig");
pub const URL = @import("browser/URL.zig");
@@ -259,9 +259,6 @@ pub fn RC(comptime T: type) type {
return;
}
value.deinit(session);
if (session.finalizer_callbacks.fetchRemove(@intFromPtr(value))) |kv| {
session.releaseArena(kv.value.arena);
}
}
pub fn format(self: @This(), writer: *std.Io.Writer) !void {

View File

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

View File

@@ -144,11 +144,22 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
app.network.run();
},
.mcp => {
.mcp => |opts| {
log.info(.mcp, "starting server", .{});
log.opts.format = .logfmt;
var cdp_server: ?*lp.Server = null;
if (opts.cdp_port) |port| {
const address = std.net.Address.parseIp("127.0.0.1", port) catch |err| {
log.fatal(.mcp, "invalid cdp address", .{ .err = err, .port = port });
return;
};
cdp_server = try lp.Server.init(app, address);
try sighandler.on(lp.Server.shutdown, .{cdp_server.?});
}
defer if (cdp_server) |s| s.deinit();
var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ allocator, app });
defer worker_thread.join();

View File

@@ -9,57 +9,72 @@ const protocol = @import("protocol.zig");
const Server = @import("Server.zig");
const CDPNode = @import("../cdp/Node.zig");
const goto_schema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }
\\ },
\\ "required": ["url"]
\\}
);
const url_params_schema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before processing." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }
\\ }
\\}
);
const evaluate_schema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "script": { "type": "string" },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }
\\ },
\\ "required": ["script"]
\\}
);
pub const tool_list = [_]protocol.Tool{
.{
.name = "goto",
.description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." }
\\ },
\\ "required": ["url"]
\\}
),
.inputSchema = goto_schema,
},
.{
.name = "navigate",
.description = "Alias for goto. Navigate to a specified URL and load the page in memory.",
.inputSchema = goto_schema,
},
.{
.name = "markdown",
.description = "Get the page content in markdown format. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching markdown." }
\\ }
\\}
),
.inputSchema = url_params_schema,
},
.{
.name = "links",
.description = "Extract all links in the opened page. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting links." }
\\ }
\\}
),
.inputSchema = url_params_schema,
},
.{
.name = "evaluate",
.description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "script": { "type": "string" },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." }
\\ },
\\ "required": ["script"]
\\}
),
.inputSchema = evaluate_schema,
},
.{
.name = "eval",
.description = "Alias for evaluate. Evaluate JavaScript in the current page context.",
.inputSchema = evaluate_schema,
},
.{
.name = "semantic_tree",
@@ -69,6 +84,8 @@ pub const tool_list = [_]protocol.Tool{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." },
\\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." }
\\ }
@@ -91,38 +108,17 @@ pub const tool_list = [_]protocol.Tool{
.{
.name = "interactiveElements",
.description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting interactive elements." }
\\ }
\\}
),
.inputSchema = url_params_schema,
},
.{
.name = "structuredData",
.description = "Extract structured data (like JSON-LD, OpenGraph, etc) from the opened page. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting structured data." }
\\ }
\\}
),
.inputSchema = url_params_schema,
},
.{
.name = "detectForms",
.description = "Detect all forms on the page and return their structure including fields, types, and required status. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before detecting forms." }
\\ }
\\}
),
.inputSchema = url_params_schema,
},
.{
.name = "click",
@@ -189,15 +185,21 @@ pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
const GotoParams = struct {
url: [:0]const u8,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const UrlParams = struct {
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const EvaluateParams = struct {
script: [:0]const u8,
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const ToolStreamingText = struct {
@@ -274,6 +276,7 @@ const ToolAction = enum {
structuredData,
detectForms,
evaluate,
eval,
semantic_tree,
click,
fill,
@@ -291,6 +294,7 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
.{ "structuredData", .structuredData },
.{ "detectForms", .detectForms },
.{ "evaluate", .evaluate },
.{ "eval", .eval },
.{ "semantic_tree", .semantic_tree },
.{ "click", .click },
.{ "fill", .fill },
@@ -324,7 +328,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
.interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments),
.structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments),
.detectForms => try handleDetectForms(server, arena, req.id.?, call_params.arguments),
.evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments),
.eval, .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments),
.semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments),
.click => try handleClick(server, arena, req.id.?, call_params.arguments),
.fill => try handleFill(server, arena, req.id.?, call_params.arguments),
@@ -335,7 +339,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgs(GotoParams, arena, arguments, server, id, "goto");
try performGoto(server, args.url, id);
try performGoto(server, args.url, id, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
@@ -343,7 +347,7 @@ fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .markdown },
@@ -355,7 +359,7 @@ fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value,
fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .links },
@@ -370,9 +374,11 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
url: ?[:0]const u8 = null,
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = try parseArgsOrDefault(TreeParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{
@@ -417,7 +423,7 @@ fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Val
fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "elements collection failed", .{ .err = err });
@@ -438,7 +444,7 @@ fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.
fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "struct data collection failed", .{ .err = err });
@@ -453,7 +459,7 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.
fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| {
log.err(.mcp, "form collection failed", .{ .err = err });
@@ -474,7 +480,7 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgs(EvaluateParams, arena, arguments, server, id, "evaluate");
const page = try ensurePage(server, id, args.url);
const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
@@ -630,9 +636,9 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page {
fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page {
if (url) |u| {
try performGoto(server, u, id);
try performGoto(server, u, id, timeout, waitUntil);
}
return server.session.currentPage() orelse {
try server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -668,7 +674,7 @@ fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Va
};
}
fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !void {
const session = server.session;
if (session.page != null) {
session.removePage();
@@ -689,8 +695,11 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
try server.sendError(id, .InternalError, "Failed to start page runner");
return error.NavigationFailed;
};
runner.wait(.{ .ms = 2000 }) catch {
try server.sendError(id, .InternalError, "Timeout waiting for page load");
runner.wait(.{
.ms = timeout orelse 10000,
.until = waitUntil orelse .done,
}) catch {
try server.sendError(id, .InternalError, "Error waiting for page load");
return error.NavigationFailed;
};
}

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../log.zig");
const builtin = @import("builtin");
const net = std.net;
const posix = std.posix;
@@ -26,11 +27,15 @@ const lp = @import("lightpanda");
const Config = @import("../Config.zig");
const libcurl = @import("../sys/libcurl.zig");
const net_http = @import("http.zig");
const http = @import("http.zig");
const RobotStore = @import("Robots.zig").RobotStore;
const WebBotAuth = @import("WebBotAuth.zig");
const Runtime = @This();
const Cache = @import("cache/Cache.zig");
const FsCache = @import("cache/FsCache.zig");
const App = @import("../App.zig");
const Network = @This();
const Listener = struct {
socket: posix.socket_t,
@@ -45,15 +50,22 @@ const MAX_TICK_CALLBACKS = 16;
allocator: Allocator,
app: *App,
config: *const Config,
ca_blob: ?net_http.Blob,
ca_blob: ?http.Blob,
robot_store: RobotStore,
web_bot_auth: ?WebBotAuth,
cache: ?Cache,
connections: []net_http.Connection,
connections: []http.Connection,
available: std.DoublyLinkedList = .{},
conn_mutex: std.Thread.Mutex = .{},
ws_pool: std.heap.MemoryPool(http.Connection),
ws_count: usize = 0,
ws_max: u8,
ws_mutex: std.Thread.Mutex = .{},
pollfds: []posix.pollfd,
listener: ?Listener = null,
@@ -63,8 +75,8 @@ wakeup_pipe: [2]posix.fd_t = .{ -1, -1 },
shutdown: std.atomic.Value(bool) = .init(false),
// Multi is a heavy structure that can consume up to 2MB of RAM.
// Currently, Runtime is used sparingly, and we only create it on demand.
// When Runtime becomes truly shared, it should become a regular field.
// Currently, Network is used sparingly, and we only create it on demand.
// When Network becomes truly shared, it should become a regular field.
multi: ?*libcurl.CurlM = null,
submission_mutex: std.Thread.Mutex = .{},
submission_queue: std.DoublyLinkedList = .{},
@@ -200,7 +212,7 @@ fn globalDeinit() void {
libcurl.curl_global_cleanup();
}
pub fn init(allocator: Allocator, config: *const Config) !Runtime {
pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network {
globalInit(allocator);
errdefer globalDeinit();
@@ -213,18 +225,18 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
@memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 });
pollfds[0] = .{ .fd = pipe[0], .events = posix.POLL.IN, .revents = 0 };
var ca_blob: ?net_http.Blob = null;
var ca_blob: ?http.Blob = null;
if (config.tlsVerifyHost()) {
ca_blob = try loadCerts(allocator);
}
const count: usize = config.httpMaxConcurrent();
const connections = try allocator.alloc(net_http.Connection, count);
const connections = try allocator.alloc(http.Connection, count);
errdefer allocator.free(connections);
var available: std.DoublyLinkedList = .{};
for (0..count) |i| {
connections[i] = try net_http.Connection.init(ca_blob, config);
connections[i] = try http.Connection.init(ca_blob, config);
available.append(&connections[i].node);
}
@@ -233,6 +245,22 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
else
null;
const cache = if (config.httpCacheDir()) |cache_dir_path|
Cache{
.kind = .{
.fs = FsCache.init(cache_dir_path) catch |e| {
log.err(.cache, "failed to init", .{
.kind = "FsCache",
.path = cache_dir_path,
.err = e,
});
return e;
},
},
}
else
null;
return .{
.allocator = allocator,
.config = config,
@@ -244,12 +272,18 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
.available = available,
.connections = connections,
.app = app,
.robot_store = RobotStore.init(allocator),
.web_bot_auth = web_bot_auth,
.cache = cache,
.ws_pool = .init(allocator),
.ws_max = config.wsMaxConcurrent(),
};
}
pub fn deinit(self: *Runtime) void {
pub fn deinit(self: *Network) void {
if (self.multi) |multi| {
libcurl.curl_multi_cleanup(multi) catch {};
}
@@ -273,16 +307,20 @@ pub fn deinit(self: *Runtime) void {
}
self.allocator.free(self.connections);
self.ws_pool.deinit();
self.robot_store.deinit();
if (self.web_bot_auth) |wba| {
wba.deinit(self.allocator);
}
if (self.cache) |*cache| cache.deinit();
globalDeinit();
}
pub fn bind(
self: *Runtime,
self: *Network,
address: net.Address,
ctx: *anyopaque,
on_accept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void,
@@ -313,7 +351,7 @@ pub fn bind(
};
}
pub fn onTick(self: *Runtime, ctx: *anyopaque, callback: *const fn (*anyopaque) void) void {
pub fn onTick(self: *Network, ctx: *anyopaque, callback: *const fn (*anyopaque) void) void {
self.callbacks_mutex.lock();
defer self.callbacks_mutex.unlock();
@@ -328,7 +366,7 @@ pub fn onTick(self: *Runtime, ctx: *anyopaque, callback: *const fn (*anyopaque)
self.wakeupPoll();
}
pub fn fireTicks(self: *Runtime) void {
pub fn fireTicks(self: *Network) void {
self.callbacks_mutex.lock();
defer self.callbacks_mutex.unlock();
@@ -337,7 +375,7 @@ pub fn fireTicks(self: *Runtime) void {
}
}
pub fn run(self: *Runtime) void {
pub fn run(self: *Network) void {
var drain_buf: [64]u8 = undefined;
var running_handles: c_int = 0;
@@ -428,18 +466,18 @@ pub fn run(self: *Runtime) void {
}
}
pub fn submitRequest(self: *Runtime, conn: *net_http.Connection) void {
pub fn submitRequest(self: *Network, conn: *http.Connection) void {
self.submission_mutex.lock();
self.submission_queue.append(&conn.node);
self.submission_mutex.unlock();
self.wakeupPoll();
}
fn wakeupPoll(self: *Runtime) void {
fn wakeupPoll(self: *Network) void {
_ = posix.write(self.wakeup_pipe[1], &.{1}) catch {};
}
fn drainQueue(self: *Runtime) void {
fn drainQueue(self: *Network) void {
self.submission_mutex.lock();
defer self.submission_mutex.unlock();
@@ -455,7 +493,7 @@ fn drainQueue(self: *Runtime) void {
};
while (self.submission_queue.popFirst()) |node| {
const conn: *net_http.Connection = @fieldParentPtr("node", node);
const conn: *http.Connection = @fieldParentPtr("node", node);
conn.setPrivate(conn) catch |err| {
lp.log.err(.app, "curl set private", .{ .err = err });
self.releaseConnection(conn);
@@ -468,12 +506,12 @@ fn drainQueue(self: *Runtime) void {
}
}
pub fn stop(self: *Runtime) void {
pub fn stop(self: *Network) void {
self.shutdown.store(true, .release);
self.wakeupPoll();
}
fn acceptConnections(self: *Runtime) void {
fn acceptConnections(self: *Network) void {
if (self.shutdown.load(.acquire)) {
return;
}
@@ -503,7 +541,7 @@ fn acceptConnections(self: *Runtime) void {
}
}
fn preparePollFds(self: *Runtime, multi: *libcurl.CurlM) void {
fn preparePollFds(self: *Network, multi: *libcurl.CurlM) void {
const curl_fds = self.pollfds[PSEUDO_POLLFDS..];
@memset(curl_fds, .{ .fd = -1, .events = 0, .revents = 0 });
@@ -514,14 +552,14 @@ fn preparePollFds(self: *Runtime, multi: *libcurl.CurlM) void {
};
}
fn getCurlTimeout(self: *Runtime) i32 {
fn getCurlTimeout(self: *Network) i32 {
const multi = self.multi orelse return -1;
var timeout_ms: c_long = -1;
libcurl.curl_multi_timeout(multi, &timeout_ms) catch return -1;
return @intCast(@min(timeout_ms, std.math.maxInt(i32)));
}
fn processCompletions(self: *Runtime, multi: *libcurl.CurlM) void {
fn processCompletions(self: *Network, multi: *libcurl.CurlM) void {
var msgs_in_queue: c_int = 0;
while (libcurl.curl_multi_info_read(multi, &msgs_in_queue)) |msg| {
switch (msg.data) {
@@ -537,7 +575,7 @@ fn processCompletions(self: *Runtime, multi: *libcurl.CurlM) void {
var ptr: *anyopaque = undefined;
libcurl.curl_easy_getinfo(easy, .private, &ptr) catch
lp.assert(false, "curl getinfo private", .{});
const conn: *net_http.Connection = @ptrCast(@alignCast(ptr));
const conn: *http.Connection = @ptrCast(@alignCast(ptr));
libcurl.curl_multi_remove_handle(multi, easy) catch {};
self.releaseConnection(conn);
@@ -556,7 +594,7 @@ comptime {
}
}
pub fn getConnection(self: *Runtime) ?*net_http.Connection {
pub fn getConnection(self: *Network) ?*http.Connection {
self.conn_mutex.lock();
defer self.conn_mutex.unlock();
@@ -564,19 +602,51 @@ pub fn getConnection(self: *Runtime) ?*net_http.Connection {
return @fieldParentPtr("node", node);
}
pub fn releaseConnection(self: *Runtime, conn: *net_http.Connection) void {
conn.reset(self.config, self.ca_blob) catch |err| {
lp.assert(false, "couldn't reset curl easy", .{ .err = err });
};
self.conn_mutex.lock();
defer self.conn_mutex.unlock();
self.available.append(&conn.node);
pub fn releaseConnection(self: *Network, conn: *http.Connection) void {
switch (conn.transport) {
.websocket => {
conn.deinit();
self.ws_mutex.lock();
defer self.ws_mutex.unlock();
self.ws_pool.destroy(conn);
self.ws_count -= 1;
},
else => {
conn.reset(self.config, self.ca_blob) catch |err| {
lp.assert(false, "couldn't reset curl easy", .{ .err = err });
};
self.conn_mutex.lock();
defer self.conn_mutex.unlock();
self.available.append(&conn.node);
},
}
}
pub fn newConnection(self: *Runtime) !net_http.Connection {
return net_http.Connection.init(self.ca_blob, self.config);
pub fn newConnection(self: *Network) ?*http.Connection {
const conn = blk: {
self.ws_mutex.lock();
defer self.ws_mutex.unlock();
if (self.ws_count >= self.ws_max) {
return null;
}
const c = self.ws_pool.create() catch return null;
self.ws_count += 1;
break :blk c;
};
// don't do this under lock
conn.* = http.Connection.init(self.ca_blob, self.config) catch {
self.ws_mutex.lock();
defer self.ws_mutex.unlock();
self.ws_pool.destroy(conn);
self.ws_count -= 1;
return null;
};
return conn;
}
// Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is

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

@@ -0,0 +1,213 @@
// 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 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 fn format(self: CachedMetadata, writer: *std.Io.Writer) !void {
try writer.print("url={s} | status={d} | content_type={s} | max_age={d} | vary=[", .{
self.url,
self.status,
self.content_type,
self.cache_control.max_age,
});
// Logging all headers gets pretty verbose...
// so we just log the Vary ones that matter for caching.
if (self.vary_headers.len > 0) {
for (self.vary_headers, 0..) |hdr, i| {
if (i > 0) try writer.print(", ", .{});
try writer.print("{s}: {s}", .{ hdr.name, hdr.value });
}
}
try writer.print("]", .{});
}
};
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 fn format(self: CachedData, writer: *std.Io.Writer) !void {
switch (self) {
.buffer => |buf| try writer.print("buffer({d} bytes)", .{buf.len}),
.file => |f| try writer.print("file(offset={d}, len={d} bytes)", .{ f.offset, f.len }),
}
}
};
pub const CachedResponse = struct {
metadata: CachedMetadata,
data: CachedData,
pub fn format(self: *const CachedResponse, writer: *std.Io.Writer) !void {
try writer.print("metadata=(", .{});
try self.metadata.format(writer);
try writer.print("), data=", .{});
try self.data.format(writer);
}
};
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) {
log.debug(.cache, "no store", .{ .url = url, .code = status, .reason = "status" });
return null;
}
if (has_set_cookie) {
log.debug(.cache, "no store", .{ .url = url, .reason = "has_cookies" });
return null;
}
if (has_authorization) {
log.debug(.cache, "no store", .{ .url = url, .reason = "has_authorization" });
return null;
}
if (vary) |v| if (std.mem.eql(u8, v, "*")) {
log.debug(.cache, "no store", .{ .url = url, .vary = v, .reason = "vary" });
return null;
};
const cc = blk: {
if (cache_control == null) {
log.debug(.cache, "no store", .{ .url = url, .reason = "no cache control" });
return null;
}
if (CacheControl.parse(cache_control.?)) |cc| {
break :blk cc;
}
log.debug(.cache, "no store", .{ .url = url, .cache_control = cache_control.?, .reason = "cache control" });
return null;
};
return .{
.url = try arena.dupeZ(u8, 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 = &.{},
};
}

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

@@ -0,0 +1,612 @@
// 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, .reason = "missing" });
},
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| {
// Warn because malformed metadata can be a deeper symptom.
log.warn(.cache, "miss", .{ .url = req.url, .err = e, .reason = "malformed metadata" });
cleanup = true;
return null;
};
if (cache_file.version != CACHE_VERSION) {
log.debug(.cache, "miss", .{
.url = req.url,
.reason = "version mismatch",
.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, "miss", .{ .url = req.url, .reason = "expired" });
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, "miss", .{
.url = req.url,
.reason = "vary mismatch",
.header = name,
.expected = value,
.got = incoming,
});
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;
}
log.debug(.cache, "hit", .{ .url = req.url, .hash = &hashed_key });
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 = self.dir.createFile(&cache_tmp_p, .{ .truncate = true }) catch |e| {
log.err(.cache, "create file", .{ .url = meta.url, .file = &cache_tmp_p, .err = e });
return e;
};
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);
file_writer_iface.writeAll(&len_buf) catch |e| {
log.err(.cache, "write body len", .{ .url = meta.url, .err = e });
return e;
};
file_writer_iface.writeAll(body) catch |e| {
log.err(.cache, "write body", .{ .url = meta.url, .err = e });
return e;
};
std.json.Stringify.value(
CacheMetadataJson{ .version = CACHE_VERSION, .metadata = meta },
.{ .whitespace = .minified },
file_writer_iface,
) catch |e| {
log.err(.cache, "write metadata", .{ .url = meta.url, .err = e });
return e;
};
file_writer_iface.flush() catch |e| {
log.err(.cache, "flush", .{ .url = meta.url, .err = e });
return e;
};
self.dir.rename(&cache_tmp_p, &cache_p) catch |e| {
log.err(.cache, "rename", .{ .url = meta.url, .from = &cache_tmp_p, .to = &cache_p, .err = e });
return e;
};
log.debug(.cache, "put", .{ .url = meta.url, .hash = &hashed_key, .body_len = body.len });
}
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,7 +28,9 @@ pub const ENABLE_DEBUG = false;
pub const Blob = libcurl.CurlBlob;
pub const WaitFd = libcurl.CurlWaitFd;
pub const readfunc_pause = libcurl.curl_readfunc_pause;
pub const writefunc_error = libcurl.curl_writefunc_error;
pub const WsFrameType = libcurl.WsFrameType;
const Error = libcurl.Error;
@@ -79,7 +81,7 @@ pub const Headers = struct {
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 name = std.mem.trim(u8, header_str[0..colon_pos], " \t");
@@ -88,22 +90,9 @@ pub const Headers = struct {
return .{ .name = name, .value = value };
}
pub fn iterator(self: *Headers) Iterator {
return .{
.header = self.headers,
};
pub fn iterator(self: Headers) HeaderIterator {
return .{ .curl_slist = .{ .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.
@@ -112,6 +101,7 @@ pub const Headers = struct {
// This union, is an iterator that exposes the same API for either case.
pub const HeaderIterator = union(enum) {
curl: CurlHeaderIterator,
curl_slist: CurlSListIterator,
list: ListHeaderIterator,
pub fn next(self: *HeaderIterator) ?Header {
@@ -120,6 +110,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 {
conn: *const Connection,
prev: ?*libcurl.CurlHeader = null,
@@ -136,6 +139,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 {
index: usize = 0,
list: []const Header,
@@ -211,15 +224,19 @@ pub const ResponseHead = struct {
pub const Connection = struct {
_easy: *libcurl.Curl,
transport: Transport,
node: std.DoublyLinkedList.Node = .{},
pub fn init(
ca_blob: ?libcurl.CurlBlob,
config: *const Config,
) !Connection {
pub const Transport = union(enum) {
none, // used for cases that manage their own connection, e.g. telemetry
http: *@import("../browser/HttpClient.zig").Transfer,
websocket: *@import("../browser/webapi/net/WebSocket.zig"),
};
pub fn init(ca_blob: ?libcurl.CurlBlob, config: *const Config) !Connection {
const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy;
const self = Connection{ ._easy = easy };
var self = Connection{ ._easy = easy, .transport = .none };
errdefer self.deinit();
try self.reset(config, ca_blob);
@@ -299,7 +316,12 @@ pub const Connection = struct {
try libcurl.curl_easy_setopt(self._easy, .user_pwd, creds.ptr);
}
pub fn setCallbacks(
pub fn setConnectOnly(self: *const Connection, connect_only: bool) !void {
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,
comptime data_cb: libcurl.CurlWriteFunction,
) !void {
@@ -307,12 +329,40 @@ pub const Connection = struct {
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 fn pause(
self: *Connection,
flags: libcurl.CurlPauseFlags,
) !void {
try libcurl.curl_easy_pause(self._easy, flags);
}
pub fn reset(
self: *const Connection,
self: *Connection,
config: *const Config,
ca_blob: ?libcurl.CurlBlob,
) !void {
libcurl.curl_easy_reset(self._easy);
self.transport = .none;
// timeouts
try libcurl.curl_easy_setopt(self._easy, .timeout_ms, config.httpTimeout());
@@ -449,12 +499,6 @@ 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.
pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void {
if (http_headers.proxy_bearer_header) |hdr| {
@@ -471,6 +515,14 @@ pub const Connection = struct {
try libcurl.curl_easy_perform(self._easy);
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 {
@@ -508,17 +560,21 @@ pub const Handles = struct {
}
pub const MultiMessage = struct {
conn: Connection,
conn: *Connection,
err: ?Error,
};
pub fn readMessage(self: *Handles) ?MultiMessage {
pub fn readMessage(self: *Handles) !?MultiMessage {
var messages_count: c_int = 0;
const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null;
return switch (msg.data) {
.done => |err| .{
.conn = .{ ._easy = msg.easy_handle },
.err = err,
.done => |err| {
var private: *anyopaque = undefined;
try libcurl.curl_easy_getinfo(msg.easy_handle, .private, &private);
return .{
.conn = @ptrCast(@alignCast(private)),
.err = err,
};
},
else => unreachable,
};

View File

@@ -40,6 +40,8 @@ pub const CurlDebugFunction = fn (*Curl, CurlInfoType, [*c]u8, usize, *anyopaque
pub const CurlHeaderFunction = 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_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 StrdupCallback = fn (str: [*:0]const u8) ?[*:0]u8;
@@ -98,6 +100,23 @@ pub const CurlWaitFd = extern struct {
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 {
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 {
@@ -167,6 +186,10 @@ pub const CurlOption = enum(c.CURLoption) {
header_function = c.CURLOPT_HEADERFUNCTION,
write_data = c.CURLOPT_WRITEDATA,
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) {
@@ -530,6 +553,7 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
const code = switch (option) {
.verbose,
.post,
.upload,
.http_get,
.ssl_verify_host,
.ssl_verify_peer,
@@ -551,6 +575,7 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.max_redirs,
.follow_location,
.post_field_size,
.connect_only,
=> blk: {
const n: c_long = switch (@typeInfo(@TypeOf(value))) {
.comptime_int, .int => @intCast(value),
@@ -593,6 +618,7 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.private,
.header_data,
.read_data,
.write_data,
=> blk: {
const ptr: ?*anyopaque = switch (@typeInfo(@TypeOf(value))) {
@@ -631,6 +657,22 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
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: {
const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) {
.null => null,
@@ -677,6 +719,10 @@ pub fn curl_easy_getinfo(easy: *Curl, comptime info: CurlInfo, out: anytype) Err
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(
easy: *Curl,
name: [*:0]const u8,
@@ -804,3 +850,79 @@ pub fn curl_slist_free_all(list: ?*CurlSList) void {
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

@@ -8,7 +8,7 @@ const log = @import("../log.zig");
const App = @import("../App.zig");
const Config = @import("../Config.zig");
const telemetry = @import("telemetry.zig");
const Runtime = @import("../network/Runtime.zig");
const Network = @import("../network/Network.zig");
const URL = "https://telemetry.lightpanda.io";
const BUFFER_SIZE = 1024;
@@ -17,7 +17,7 @@ const MAX_BODY_SIZE = 500 * 1024; // 500KB server limit
const LightPanda = @This();
allocator: Allocator,
runtime: *Runtime,
network: *Network,
writer: std.Io.Writer.Allocating,
/// Protects concurrent producers in send().
@@ -36,11 +36,11 @@ pub fn init(self: *LightPanda, app: *App, iid: ?[36]u8, run_mode: Config.RunMode
.iid = iid,
.run_mode = run_mode,
.allocator = app.allocator,
.runtime = &app.network,
.network = &app.network,
.writer = std.Io.Writer.Allocating.init(app.allocator),
};
self.runtime.onTick(@ptrCast(self), flushCallback);
self.network.onTick(@ptrCast(self), flushCallback);
}
pub fn deinit(self: *LightPanda) void {
@@ -70,17 +70,17 @@ fn flushCallback(ctx: *anyopaque) void {
}
fn postEvent(self: *LightPanda) !void {
const conn = self.runtime.getConnection() orelse {
const conn = self.network.getConnection() orelse {
return;
};
errdefer self.runtime.releaseConnection(conn);
errdefer self.network.releaseConnection(conn);
const h = self.head.load(.monotonic);
const t = self.tail.load(.acquire);
const dropped = self.dropped.swap(0, .monotonic);
if (h == t and dropped == 0) {
self.runtime.releaseConnection(conn);
self.network.releaseConnection(conn);
return;
}
errdefer _ = self.dropped.fetchAdd(dropped, .monotonic);
@@ -104,7 +104,7 @@ fn postEvent(self: *LightPanda) !void {
try conn.setBody(self.writer.written());
self.head.store(h + sent, .release);
self.runtime.submitRequest(conn);
self.network.submitRequest(conn);
}
fn writeEvent(self: *LightPanda, event: telemetry.Event) !bool {

View File

@@ -436,19 +436,17 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
if (js_val.isTrue()) {
return;
}
switch (try runner.tick(.{ .ms = 20 })) {
.done => return error.TestNeverSignaledCompletion,
.ok => |next_ms| {
const ms_elapsed = timer.lap() / 1_000_000;
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);
}
},
const sleep_ms: usize = switch (try runner.tick(.{ .ms = 20 })) {
.done => 20,
.ok => |next_ms| @min(next_ms, 20),
};
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= wait_ms) {
return error.TestTimedOut;
}
wait_ms -= @intCast(ms_elapsed);
std.Thread.sleep(std.time.ns_per_ms * sleep_ms);
}
}
@@ -476,12 +474,15 @@ pub fn pageTest(comptime test_file: []const u8, opts: PageTestOpts) !*Page {
const log = @import("log.zig");
const TestHTTPServer = @import("TestHTTPServer.zig");
const TestWSServer = @import("TestWSServer.zig");
const Server = @import("Server.zig");
var test_cdp_server: ?*Server = null;
var test_cdp_server_thread: ?std.Thread = null;
var test_http_server: ?TestHTTPServer = 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;
@@ -495,6 +496,7 @@ test "tests:beforeAll" {
.common = .{
.tls_verify_host = false,
.user_agent_suffix = "internal-tester",
.ws_max_concurrent = 50,
},
} });
@@ -514,13 +516,16 @@ test "tests:beforeAll" {
test_session = try test_browser.newSession(test_notification);
var wg: std.Thread.WaitGroup = .{};
wg.startMany(2);
wg.startMany(3);
test_cdp_server_thread = try std.Thread.spawn(.{}, serveCDP, .{&wg});
test_http_server = TestHTTPServer.init(testHTTPHandler);
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
// they aren't able to connect.
wg.wait();
@@ -545,6 +550,13 @@ test "tests:afterAll" {
server.deinit();
}
if (test_ws_server) |*server| {
server.stop();
}
if (test_ws_server_thread) |thread| {
thread.join();
}
@import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size;
test_notification.deinit();