Merge branch 'main' into mcp

This commit is contained in:
Adrià Arrufat
2026-03-02 11:55:36 +09:00
177 changed files with 7463 additions and 4563 deletions

View File

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

View File

@@ -63,6 +63,6 @@ jobs:
- name: run end to end integration tests - name: run end to end integration tests
run: | run: |
./lightpanda serve & echo $! > LPD.pid ./lightpanda serve --log_level error & echo $! > LPD.pid
go run integration/main.go go run integration/main.go
kill `cat LPD.pid` kill `cat LPD.pid`

View File

@@ -15,11 +15,11 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
wpt: wpt-build-release:
name: web platform tests json output name: zig build release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 90 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -30,11 +30,85 @@ jobs:
- uses: ./.github/actions/install - uses: ./.github/actions/install
- name: build wpt - name: zig build release
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -- version run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: lightpanda-build-release
path: |
zig-out/bin/lightpanda
retention-days: 1
wpt-build-runner:
name: build wpt runner
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: |
cd ./wptrunner
CGO_ENABLED=0 go build
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: wptrunner
path: |
wptrunner/wptrunner
retention-days: 1
run-wpt:
name: web platform tests json output
needs:
- wpt-build-release
- wpt-build-runner
# use a self host runner.
runs-on: lpd-bench-hetzner
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
with:
ref: fork
repository: 'lightpanda-io/wpt'
fetch-depth: 0
# The hosts are configured manually on the self host runner.
# - name: create custom hosts
# run: ./wpt make-hosts-file | sudo tee -a /etc/hosts
- name: generate manifest
run: ./wpt manifest
- name: download lightpanda release
uses: actions/download-artifact@v4
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- name: download wptrunner
uses: actions/download-artifact@v4
with:
name: wptrunner
- run: chmod a+x ./wptrunner
- name: run test with json output - name: run test with json output
run: zig-out/bin/lightpanda-wpt --json > wpt.json run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 10s
./wptrunner -lpd-path ./lightpanda -json -concurrency 1 > wpt.json
kill `cat WPT.pid`
- name: write commit - name: write commit
run: | run: |
@@ -51,7 +125,7 @@ jobs:
perf-fmt: perf-fmt:
name: perf-fmt name: perf-fmt
needs: wpt needs: run-wpt
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15

15
.gitmodules vendored
View File

@@ -1,15 +0,0 @@
[submodule "tests/wpt"]
path = tests/wpt
url = https://github.com/lightpanda-io/wpt
[submodule "vendor/nghttp2"]
path = vendor/nghttp2
url = https://github.com/nghttp2/nghttp2.git
[submodule "vendor/zlib"]
path = vendor/zlib
url = https://github.com/madler/zlib.git
[submodule "vendor/curl"]
path = vendor/curl
url = https://github.com/curl/curl.git
[submodule "vendor/brotli"]
path = vendor/brotli
url = https://github.com/google/brotli

View File

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

View File

@@ -47,7 +47,7 @@ help:
# $(ZIG) commands # $(ZIG) commands
# ------------ # ------------
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench wpt data end2end .PHONY: build build-v8-snapshot build-dev run run-release shell test bench data end2end
## Build v8 snapshot ## Build v8 snapshot
build-v8-snapshot: build-v8-snapshot:
@@ -82,15 +82,6 @@ shell:
@printf "\033[36mBuilding shell...\033[0m\n" @printf "\033[36mBuilding shell...\033[0m\n"
@$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
## Run WPT tests
wpt:
@printf "\033[36mBuilding wpt...\033[0m\n"
@$(ZIG) build wpt -- $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
wpt-summary:
@printf "\033[36mBuilding wpt...\033[0m\n"
@$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
## Test - `grep` is used to filter out the huge compile command on build ## Test - `grep` is used to filter out the huge compile command on build
ifeq ($(OS), macos) ifeq ($(OS), macos)
test: test:
@@ -111,13 +102,8 @@ end2end:
# ------------ # ------------
.PHONY: install .PHONY: install
## Install and build dependencies for release install: build
install: install-submodule
data: data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
## Init and update git submodule
install-submodule:
@git submodule init && \
git submodule update

View File

@@ -220,18 +220,6 @@ For **MacOS**, you need cmake and [Rust](https://rust-lang.org/tools/install/).
brew install cmake brew install cmake
``` ```
### Install Git submodules
The project uses git submodules for dependencies.
To init or update the submodules in the `vendor/` directory:
```
make install-submodule
```
This is an alias for `git submodule init && git submodule update`.
### Build and run ### Build and run
You an build the entire browser with `make build` or `make build-dev` for debug You an build the entire browser with `make build` or `make build-dev` for debug
@@ -281,35 +269,75 @@ make end2end
Lightpanda is tested against the standardized [Web Platform Lightpanda is tested against the standardized [Web Platform
Tests](https://web-platform-tests.org/). Tests](https://web-platform-tests.org/).
The relevant tests cases are committed in a [dedicated repository](https://github.com/lightpanda-io/wpt) which is fetched by the `make install-submodule` command. We use [a fork](https://github.com/lightpanda-io/wpt/tree/fork) including a custom
[`testharnessreport.js`](https://github.com/lightpanda-io/wpt/commit/01a3115c076a3ad0c84849dbbf77a6e3d199c56f).
All the tests cases executed are located in the `tests/wpt` sub-directory.
For reference, you can easily execute a WPT test case with your browser via For reference, you can easily execute a WPT test case with your browser via
[wpt.live](https://wpt.live). [wpt.live](https://wpt.live).
#### Configure WPT HTTP server
To run the test, you must clone the repository, configure the custom hosts and generate the
`MANIFEST.json` file.
Clone the repository with the `fork` branch.
```
git clone -b fork --depth=1 git@github.com:lightpanda-io/wpt.git
```
Enter into the `wpt/` dir.
Install custom domains in your `/etc/hosts`
```
./wpt make-hosts-file | sudo tee -a /etc/hosts
```
Generate `MANIFEST.json`
```
./wpt manifest
```
Use the [WPT's setup
guide](https://web-platform-tests.org/running-tests/from-local-system.html) for
details.
#### Run WPT test suite #### Run WPT test suite
To run all the tests: An external [Go](https://go.dev) runner is provided by
[github.com/lightpanda-io/demo/](https://github.com/lightpanda-io/demo/)
repository, located into `wptrunner/` dir.
You need to clone the project first.
First start the WPT's HTTP server from your `wpt/` clone dir.
```
./wpt serve
```
Run a Lightpanda browser
``` ```
make wpt zig build run -- --insecure_disable_tls_host_verification
```
Then you can start the wptrunner from the Demo's clone dir:
```
cd wptrunner && go run .
``` ```
Or one specific test: Or one specific test:
``` ```
make wpt Node-childNodes.html cd wptrunner && go run . Node-childNodes.html
``` ```
#### Add a new WPT test case `wptrunner` command accepts `--summary` and `--json` options modifying output.
Also `--concurrency` define the concurrency limit.
We add new relevant tests cases files when we implemented changes in Lightpanda. :warning: Running the whole test suite will take a long time. In this case,
it's useful to build in `releaseFast` mode to make tests faster.
To add a new test, copy the file you want from the [WPT ```
repo](https://github.com/web-platform-tests/wpt) into the `tests/wpt` directory. zig build -Doptimize=ReleaseFast run
```
:warning: Please keep the original directory tree structure of `tests/wpt`.
## Contributing ## Contributing

1055
build.zig

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,36 @@
.{ .{
.name = .browser, .name = .browser,
.paths = .{""},
.version = "0.0.0", .version = "0.0.0",
.fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications. .fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.2", .minimum_zig_version = "0.15.2",
.dependencies = .{ .dependencies = .{
.v8 = .{ .v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.2.9.tar.gz", .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.1.tar.gz",
.hash = "v8-0.0.0-xddH689vBACgpqFVEhT2wxRin-qQQSOcKJoM37MVo0rU", .hash = "v8-0.0.0-xddH64J7BAC81mkf6G9RbEJxS-W3TIRl5iFnShwbqCqy",
}, },
//.v8 = .{ .path = "../zig-v8-fork" }, //.v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{
// v1.2.0
.url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz",
.hash = "N-V-__8AAJudKgCQCuIiH6MJjAiIJHfg_tT_Ew-0vZwVkCo_",
},
.zlib = .{
.url = "https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz",
.hash = "N-V-__8AAJ2cNgAgfBtAw33Bxfu1IWISDeKKSr3DAqoAysIJ",
},
.nghttp2 = .{
.url = "https://github.com/nghttp2/nghttp2/releases/download/v1.68.0/nghttp2-1.68.0.tar.gz",
.hash = "N-V-__8AAL15vQCI63ZL6Zaz5hJg6JTEgYXGbLnMFSnf7FT3",
},
.@"boringssl-zig" = .{ .@"boringssl-zig" = .{
.url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096", .url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096",
.hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK", .hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK",
}, },
.curl = .{
.url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz",
.hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y",
},
}, },
.paths = .{""},
} }

View File

@@ -191,13 +191,15 @@ pub const Mcp = struct {
pub const DumpFormat = enum { pub const DumpFormat = enum {
html, html,
markdown, markdown,
wpt,
}; };
pub const Fetch = struct { pub const Fetch = struct {
url: [:0]const u8, url: [:0]const u8,
dump_mode: ?DumpFormat = null, dump_mode: ?DumpFormat = null,
common: Common = .{}, common: Common = .{},
withbase: bool = false, with_base: bool = false,
with_frames: bool = false,
strip: dump.Opts.Strip = .{}, strip: dump.Opts.Strip = .{},
}; };
@@ -348,6 +350,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ \\
\\--with_base Add a <base> tag in dump. Defaults to false. \\--with_base Add a <base> tag in dump. Defaults to false.
\\ \\
\\--with_frames Includes the contents of iframes. Defaults to false.
\\
++ common_options ++ ++ common_options ++
\\ \\
\\serve command \\serve command
@@ -454,6 +458,10 @@ fn inferMode(opt: []const u8) ?RunMode {
return .fetch; return .fetch;
} }
if (std.mem.eql(u8, opt, "--with_frames")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--host")) { if (std.mem.eql(u8, opt, "--host")) {
return .serve; return .serve;
} }
@@ -571,7 +579,8 @@ fn parseFetchArgs(
args: *std.process.ArgIterator, args: *std.process.ArgIterator,
) !Fetch { ) !Fetch {
var dump_mode: ?DumpFormat = null; var dump_mode: ?DumpFormat = null;
var withbase: bool = false; var with_base: bool = false;
var with_frames: bool = false;
var url: ?[:0]const u8 = null; var url: ?[:0]const u8 = null;
var common: Common = .{}; var common: Common = .{};
var strip: dump.Opts.Strip = .{}; var strip: dump.Opts.Strip = .{};
@@ -602,7 +611,12 @@ fn parseFetchArgs(
} }
if (std.mem.eql(u8, "--with_base", opt)) { if (std.mem.eql(u8, "--with_base", opt)) {
withbase = true; with_base = true;
continue;
}
if (std.mem.eql(u8, "--with_frames", opt)) {
with_frames = true;
continue; continue;
} }
@@ -658,7 +672,8 @@ fn parseFetchArgs(
.dump_mode = dump_mode, .dump_mode = dump_mode,
.strip = strip, .strip = strip,
.common = common, .common = common,
.withbase = withbase, .with_base = with_base,
.with_frames = with_frames,
}; };
} }

1375
src/Net.zig Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ const log = @import("log.zig");
const App = @import("App.zig"); const App = @import("App.zig");
const Config = @import("Config.zig"); const Config = @import("Config.zig");
const CDP = @import("cdp/cdp.zig").CDP; const CDP = @import("cdp/cdp.zig").CDP;
const Net = @import("Net.zig");
const Http = @import("http/Http.zig"); const Http = @import("http/Http.zig");
const HttpClient = @import("http/Client.zig"); const HttpClient = @import("http/Client.zig");
@@ -265,21 +266,7 @@ pub const Client = struct {
allocator: Allocator, allocator: Allocator,
app: *App, app: *App,
http: *HttpClient, http: *HttpClient,
json_version_response: []const u8, ws: Net.WsConnection,
reader: Reader(true),
socket: posix.socket_t,
socket_flags: usize,
send_arena: ArenaAllocator,
timeout_ms: u32,
const EMPTY_PONG = [_]u8{ 138, 0 };
// CLOSE, 2 length, code
const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000
const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009
const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002
// "private-use" close codes must be from 4000-49999
const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000
fn init( fn init(
socket: posix.socket_t, socket: posix.socket_t,
@@ -288,40 +275,28 @@ pub const Client = struct {
json_version_response: []const u8, json_version_response: []const u8,
timeout_ms: u32, timeout_ms: u32,
) !Client { ) !Client {
var ws = try Net.WsConnection.init(socket, allocator, json_version_response, timeout_ms);
errdefer ws.deinit();
if (log.enabled(.app, .info)) { if (log.enabled(.app, .info)) {
var client_address: std.net.Address = undefined; const client_address = ws.getAddress() catch null;
var socklen: posix.socklen_t = @sizeOf(net.Address);
try std.posix.getsockname(socket, &client_address.any, &socklen);
log.info(.app, "client connected", .{ .ip = client_address }); log.info(.app, "client connected", .{ .ip = client_address });
} }
const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0);
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
// we expect the socket to come to us as nonblocking
lp.assert(socket_flags & nonblocking == nonblocking, "Client.init blocking", .{});
var reader = try Reader(true).init(allocator);
errdefer reader.deinit();
const http = try app.http.createClient(allocator); const http = try app.http.createClient(allocator);
errdefer http.deinit(); errdefer http.deinit();
return .{ return .{
.socket = socket,
.allocator = allocator, .allocator = allocator,
.app = app, .app = app,
.http = http, .http = http,
.json_version_response = json_version_response, .ws = ws,
.reader = reader,
.mode = .{ .http = {} }, .mode = .{ .http = {} },
.socket_flags = socket_flags,
.send_arena = ArenaAllocator.init(allocator),
.timeout_ms = timeout_ms,
}; };
} }
fn stop(self: *Client) void { fn stop(self: *Client) void {
posix.shutdown(self.socket, .recv) catch {}; self.ws.shutdown();
} }
fn deinit(self: *Client) void { fn deinit(self: *Client) void {
@@ -329,15 +304,14 @@ pub const Client = struct {
.cdp => |*cdp| cdp.deinit(), .cdp => |*cdp| cdp.deinit(),
.http => {}, .http => {},
} }
self.reader.deinit(); self.ws.deinit();
self.send_arena.deinit();
self.http.deinit(); self.http.deinit();
} }
fn start(self: *Client) void { fn start(self: *Client) void {
const http = self.http; const http = self.http;
http.cdp_client = .{ http.cdp_client = .{
.socket = self.socket, .socket = self.ws.socket,
.ctx = self, .ctx = self,
.blocking_read_start = Client.blockingReadStart, .blocking_read_start = Client.blockingReadStart,
.blocking_read = Client.blockingRead, .blocking_read = Client.blockingRead,
@@ -352,8 +326,9 @@ pub const Client = struct {
fn httpLoop(self: *Client, http: *HttpClient) !void { fn httpLoop(self: *Client, http: *HttpClient) !void {
lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{}); lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{});
while (true) { while (true) {
const status = http.tick(self.timeout_ms) catch |err| { const status = http.tick(self.ws.timeout_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err }); log.err(.app, "http tick", .{ .err = err });
return; return;
}; };
@@ -371,13 +346,9 @@ pub const Client = struct {
} }
} }
return self.cdpLoop(http);
}
fn cdpLoop(self: *Client, http: *HttpClient) !void {
var cdp = &self.mode.cdp; var cdp = &self.mode.cdp;
var last_message = timestamp(.monotonic); var last_message = timestamp(.monotonic);
var ms_remaining = self.timeout_ms; var ms_remaining = self.ws.timeout_ms;
while (true) { while (true) {
switch (cdp.pageWait(ms_remaining)) { switch (cdp.pageWait(ms_remaining)) {
@@ -386,7 +357,7 @@ pub const Client = struct {
return; return;
} }
last_message = timestamp(.monotonic); last_message = timestamp(.monotonic);
ms_remaining = self.timeout_ms; ms_remaining = self.ws.timeout_ms;
}, },
.no_page => { .no_page => {
const status = http.tick(ms_remaining) catch |err| { const status = http.tick(ms_remaining) catch |err| {
@@ -401,7 +372,7 @@ pub const Client = struct {
return; return;
} }
last_message = timestamp(.monotonic); last_message = timestamp(.monotonic);
ms_remaining = self.timeout_ms; ms_remaining = self.ws.timeout_ms;
}, },
.done => { .done => {
const elapsed = timestamp(.monotonic) - last_message; const elapsed = timestamp(.monotonic) - last_message;
@@ -417,7 +388,7 @@ pub const Client = struct {
fn blockingReadStart(ctx: *anyopaque) bool { fn blockingReadStart(ctx: *anyopaque) bool {
const self: *Client = @ptrCast(@alignCast(ctx)); const self: *Client = @ptrCast(@alignCast(ctx));
_ = posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true }))) catch |err| { self.ws.setBlocking(true) catch |err| {
log.warn(.app, "CDP blockingReadStart", .{ .err = err }); log.warn(.app, "CDP blockingReadStart", .{ .err = err });
return false; return false;
}; };
@@ -431,7 +402,7 @@ pub const Client = struct {
fn blockingReadStop(ctx: *anyopaque) bool { fn blockingReadStop(ctx: *anyopaque) bool {
const self: *Client = @ptrCast(@alignCast(ctx)); const self: *Client = @ptrCast(@alignCast(ctx));
_ = posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags) catch |err| { self.ws.setBlocking(false) catch |err| {
log.warn(.app, "CDP blockingReadStop", .{ .err = err }); log.warn(.app, "CDP blockingReadStop", .{ .err = err });
return false; return false;
}; };
@@ -439,7 +410,7 @@ pub const Client = struct {
} }
fn readSocket(self: *Client) bool { fn readSocket(self: *Client) bool {
const n = posix.read(self.socket, self.readBuf()) catch |err| { const n = self.ws.read() catch |err| {
log.warn(.app, "CDP read", .{ .err = err }); log.warn(.app, "CDP read", .{ .err = err });
return false; return false;
}; };
@@ -449,16 +420,10 @@ pub const Client = struct {
return false; return false;
} }
return self.processData(n) catch false; return self.processData() catch false;
} }
fn readBuf(self: *Client) []u8 { fn processData(self: *Client) !bool {
return self.reader.readBuf();
}
fn processData(self: *Client, len: usize) !bool {
self.reader.len += len;
switch (self.mode) { switch (self.mode) {
.cdp => |*cdp| return self.processWebsocketMessage(cdp), .cdp => |*cdp| return self.processWebsocketMessage(cdp),
.http => return self.processHTTPRequest(), .http => return self.processHTTPRequest(),
@@ -466,8 +431,8 @@ pub const Client = struct {
} }
fn processHTTPRequest(self: *Client) !bool { fn processHTTPRequest(self: *Client) !bool {
lp.assert(self.reader.pos == 0, "Client.HTTP pos", .{ .pos = self.reader.pos }); lp.assert(self.ws.reader.pos == 0, "Client.HTTP pos", .{ .pos = self.ws.reader.pos });
const request = self.reader.buf[0..self.reader.len]; const request = self.ws.reader.buf[0..self.ws.reader.len];
if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) { if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) {
self.writeHTTPErrorResponse(413, "Request too large"); self.writeHTTPErrorResponse(413, "Request too large");
@@ -481,7 +446,7 @@ pub const Client = struct {
} }
// the next incoming data can go to the front of our buffer // the next incoming data can go to the front of our buffer
defer self.reader.len = 0; defer self.ws.reader.len = 0;
return self.handleHTTPRequest(request) catch |err| { return self.handleHTTPRequest(request) catch |err| {
switch (err) { switch (err) {
error.NotFound => self.writeHTTPErrorResponse(404, "Not found"), error.NotFound => self.writeHTTPErrorResponse(404, "Not found"),
@@ -521,15 +486,15 @@ pub const Client = struct {
return true; return true;
} }
if (std.mem.eql(u8, url, "/json/version")) { if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) {
try self.send(self.json_version_response); try self.ws.send(self.ws.json_version_response);
// Chromedp (a Go driver) does an http request to /json/version // Chromedp (a Go driver) does an http request to /json/version
// then to / (websocket upgrade) using a different connection. // then to / (websocket upgrade) using a different connection.
// Since we only allow 1 connection at a time, the 2nd one (the // Since we only allow 1 connection at a time, the 2nd one (the
// websocket upgrade) blocks until the first one times out. // websocket upgrade) blocks until the first one times out.
// We can avoid that by closing the connection. json_version_response // We can avoid that by closing the connection. json_version_response
// has a Connection: Close header too. // has a Connection: Close header too.
try posix.shutdown(self.socket, .recv); self.ws.shutdown();
return false; return false;
} }
@@ -537,581 +502,31 @@ pub const Client = struct {
} }
fn upgradeConnection(self: *Client, request: []u8) !void { fn upgradeConnection(self: *Client, request: []u8) !void {
// our caller already confirmed that we have a trailing \r\n\r\n try self.ws.upgrade(request);
const request_line_end = std.mem.indexOfScalar(u8, request, '\r') orelse unreachable;
const request_line = request[0..request_line_end];
if (!std.ascii.endsWithIgnoreCase(request_line, "http/1.1")) {
return error.InvalidProtocol;
}
// we need to extract the sec-websocket-key value
var key: []const u8 = "";
// we need to make sure that we got all the necessary headers + values
var required_headers: u8 = 0;
// can't std.mem.split because it forces the iterated value to be const
// (we could @constCast...)
var buf = request[request_line_end + 2 ..];
while (buf.len > 4) {
const index = std.mem.indexOfScalar(u8, buf, '\r') orelse unreachable;
const separator = std.mem.indexOfScalar(u8, buf[0..index], ':') orelse return error.InvalidRequest;
const name = std.mem.trim(u8, toLower(buf[0..separator]), &std.ascii.whitespace);
const value = std.mem.trim(u8, buf[(separator + 1)..index], &std.ascii.whitespace);
if (std.mem.eql(u8, name, "upgrade")) {
if (!std.ascii.eqlIgnoreCase("websocket", value)) {
return error.InvalidUpgradeHeader;
}
required_headers |= 1;
} else if (std.mem.eql(u8, name, "sec-websocket-version")) {
if (value.len != 2 or value[0] != '1' or value[1] != '3') {
return error.InvalidVersionHeader;
}
required_headers |= 2;
} else if (std.mem.eql(u8, name, "connection")) {
// find if connection header has upgrade in it, example header:
// Connection: keep-alive, Upgrade
if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) {
return error.InvalidConnectionHeader;
}
required_headers |= 4;
} else if (std.mem.eql(u8, name, "sec-websocket-key")) {
key = value;
required_headers |= 8;
}
const next = index + 2;
buf = buf[next..];
}
if (required_headers != 15) {
return error.MissingHeaders;
}
// our caller has already made sure this request ended in \r\n\r\n
// so it isn't something we need to check again
const allocator = self.send_arena.allocator();
const response = blk: {
// Response to an ugprade request is always this, with
// the Sec-Websocket-Accept value a spacial sha1 hash of the
// request "sec-websocket-version" and a magic value.
const template =
"HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: 0000000000000000000000000000\r\n\r\n";
// The response will be sent via the IO Loop and thus has to have its
// own lifetime.
const res = try allocator.dupe(u8, template);
// magic response
const key_pos = res.len - 32;
var h: [20]u8 = undefined;
var hasher = std.crypto.hash.Sha1.init(.{});
hasher.update(key);
// websocket spec always used this value
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
hasher.final(&h);
_ = std.base64.standard.Encoder.encode(res[key_pos .. key_pos + 28], h[0..]);
break :blk res;
};
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) }; self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) };
return self.send(response);
} }
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void { fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {
const response = std.fmt.comptimePrint( self.ws.sendHttpError(status, body);
"HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}",
.{ status, body.len, body },
);
// we're going to close this connection anyways, swallowing any
// error seems safe
self.send(response) catch {};
} }
fn processWebsocketMessage(self: *Client, cdp: *CDP) !bool { fn processWebsocketMessage(self: *Client, cdp: *CDP) !bool {
var reader = &self.reader; return self.ws.processMessages(cdp);
while (true) {
const msg = reader.next() catch |err| {
switch (err) {
error.TooLarge => self.send(&CLOSE_TOO_BIG) catch {},
error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.ControlTooLarge => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.InvalidContinuation => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.NestedFragementation => self.send(&CLOSE_PROTOCOL_ERROR) catch {},
error.OutOfMemory => {}, // don't borther trying to send an error in this case
}
return err;
} orelse break;
switch (msg.type) {
.pong => {},
.ping => try self.sendPong(msg.data),
.close => {
self.send(&CLOSE_NORMAL) catch {};
return false;
},
.text, .binary => if (cdp.handleMessage(msg.data) == false) {
return false;
},
}
if (msg.cleanup_fragment) {
reader.cleanup();
}
}
// We might have read part of the next message. Our reader potentially
// has to move data around in its buffer to make space.
reader.compact();
return true;
} }
fn sendPong(self: *Client, data: []const u8) !void { pub fn sendAllocator(self: *Client) Allocator {
if (data.len == 0) { return self.ws.send_arena.allocator();
return self.send(&EMPTY_PONG);
}
var header_buf: [10]u8 = undefined;
const header = websocketHeader(&header_buf, .pong, data.len);
const allocator = self.send_arena.allocator();
var framed = try allocator.alloc(u8, header.len + data.len);
@memcpy(framed[0..header.len], header);
@memcpy(framed[header.len..], data);
return self.send(framed);
} }
// called by CDP
// Websocket frames have a variable length header. For server-client,
// it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have
// writev, so we need to get creative. We'll JSON serialize to a
// buffer, where the first 10 bytes are reserved. We can then backfill
// the header and send the slice.
pub fn sendJSON(self: *Client, message: anytype, opts: std.json.Stringify.Options) !void { pub fn sendJSON(self: *Client, message: anytype, opts: std.json.Stringify.Options) !void {
const allocator = self.send_arena.allocator(); return self.ws.sendJSON(message, opts);
var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512);
// reserve space for the maximum possible header
try aw.writer.writeAll(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
try std.json.Stringify.value(message, opts, &aw.writer);
const framed = fillWebsocketHeader(aw.toArrayList());
return self.send(framed);
} }
pub fn sendJSONRaw( pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void {
self: *Client, return self.ws.sendJSONRaw(buf);
buf: std.ArrayList(u8),
) !void {
// Dangerous API!. We assume the caller has reserved the first 10
// bytes in `buf`.
const framed = fillWebsocketHeader(buf);
return self.send(framed);
}
fn send(self: *Client, data: []const u8) !void {
var pos: usize = 0;
var changed_to_blocking: bool = false;
defer _ = self.send_arena.reset(.{ .retain_with_limit = 1024 * 32 });
defer if (changed_to_blocking) {
// We had to change our socket to blocking me to get our write out
// We need to change it back to non-blocking.
_ = posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags) catch |err| {
log.err(.app, "CDP restore nonblocking", .{ .err = err });
};
};
LOOP: while (pos < data.len) {
const written = posix.write(self.socket, data[pos..]) catch |err| switch (err) {
error.WouldBlock => {
// self.socket is nonblocking, because we don't want to block
// reads. But our life is a lot easier if we block writes,
// largely, because we don't have to maintain a queue of pending
// writes (which would each need their own allocations). So
// if we get a WouldBlock error, we'll switch the socket to
// blocking and switch it back to non-blocking after the write
// is complete. Doesn't seem particularly efficiently, but
// this should virtually never happen.
lp.assert(changed_to_blocking == false, "Client.double block", .{});
changed_to_blocking = true;
_ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true })));
continue :LOOP;
},
else => return err,
};
if (written == 0) {
return error.Closed;
}
pos += written;
}
} }
}; };
// WebSocket message reader. Given websocket message, acts as an iterator that
// can return zero or more Messages. When next returns null, any incomplete
// message will remain in reader.data
fn Reader(comptime EXPECT_MASK: bool) type {
return struct {
allocator: Allocator,
// position in buf of the start of the next message
pos: usize = 0,
// position in buf up until where we have valid data
// (any new reads must be placed after this)
len: usize = 0,
// we add 140 to allow 1 control message (ping/pong/close) to be
// fragmented into a normal message.
buf: []u8,
fragments: ?Fragments = null,
const Self = @This();
fn init(allocator: Allocator) !Self {
const buf = try allocator.alloc(u8, 16 * 1024);
return .{
.buf = buf,
.allocator = allocator,
};
}
fn deinit(self: *Self) void {
self.cleanup();
self.allocator.free(self.buf);
}
fn cleanup(self: *Self) void {
if (self.fragments) |*f| {
f.message.deinit(self.allocator);
self.fragments = null;
}
}
fn readBuf(self: *Self) []u8 {
// We might have read a partial http or websocket message.
// Subsequent reads must read from where we left off.
return self.buf[self.len..];
}
fn next(self: *Self) !?Message {
LOOP: while (true) {
var buf = self.buf[self.pos..self.len];
const length_of_len, const message_len = extractLengths(buf) orelse {
// we don't have enough bytes
return null;
};
const byte1 = buf[0];
if (byte1 & 112 != 0) {
return error.ReservedFlags;
}
if (comptime EXPECT_MASK) {
if (buf[1] & 128 != 128) {
// client -> server messages _must_ be masked
return error.NotMasked;
}
} else if (buf[1] & 128 != 0) {
// server -> client are never masked
return error.Masked;
}
var is_control = false;
var is_continuation = false;
var message_type: Message.Type = undefined;
switch (byte1 & 15) {
0 => is_continuation = true,
1 => message_type = .text,
2 => message_type = .binary,
8 => {
is_control = true;
message_type = .close;
},
9 => {
is_control = true;
message_type = .ping;
},
10 => {
is_control = true;
message_type = .pong;
},
else => return error.InvalidMessageType,
}
if (is_control) {
if (message_len > 125) {
return error.ControlTooLarge;
}
} else if (message_len > Config.CDP_MAX_MESSAGE_SIZE) {
return error.TooLarge;
} else if (message_len > self.buf.len) {
const len = self.buf.len;
self.buf = try growBuffer(self.allocator, self.buf, message_len);
buf = self.buf[0..len];
// we need more data
return null;
} else if (buf.len < message_len) {
// we need more data
return null;
}
// prefix + length_of_len + mask
const header_len = 2 + length_of_len + if (comptime EXPECT_MASK) 4 else 0;
const payload = buf[header_len..message_len];
if (comptime EXPECT_MASK) {
mask(buf[header_len - 4 .. header_len], payload);
}
// whatever happens after this, we know where the next message starts
self.pos += message_len;
const fin = byte1 & 128 == 128;
if (is_continuation) {
const fragments = &(self.fragments orelse return error.InvalidContinuation);
if (fragments.message.items.len + message_len > Config.CDP_MAX_MESSAGE_SIZE) {
return error.TooLarge;
}
try fragments.message.appendSlice(self.allocator, payload);
if (fin == false) {
// maybe we have more parts of the message waiting
continue :LOOP;
}
// this continuation is done!
return .{
.type = fragments.type,
.data = fragments.message.items,
.cleanup_fragment = true,
};
}
const can_be_fragmented = message_type == .text or message_type == .binary;
if (self.fragments != null and can_be_fragmented) {
// if this isn't a continuation, then we can't have fragments
return error.NestedFragementation;
}
if (fin == false) {
if (can_be_fragmented == false) {
return error.InvalidContinuation;
}
// not continuation, and not fin. It has to be the first message
// in a fragmented message.
var fragments = Fragments{ .message = .{}, .type = message_type };
try fragments.message.appendSlice(self.allocator, payload);
self.fragments = fragments;
continue :LOOP;
}
return .{
.data = payload,
.type = message_type,
.cleanup_fragment = false,
};
}
}
fn extractLengths(buf: []const u8) ?struct { usize, usize } {
if (buf.len < 2) {
return null;
}
const length_of_len: usize = switch (buf[1] & 127) {
126 => 2,
127 => 8,
else => 0,
};
if (buf.len < length_of_len + 2) {
// we definitely don't have enough buf yet
return null;
}
const message_len = switch (length_of_len) {
2 => @as(u16, @intCast(buf[3])) | @as(u16, @intCast(buf[2])) << 8,
8 => @as(u64, @intCast(buf[9])) | @as(u64, @intCast(buf[8])) << 8 | @as(u64, @intCast(buf[7])) << 16 | @as(u64, @intCast(buf[6])) << 24 | @as(u64, @intCast(buf[5])) << 32 | @as(u64, @intCast(buf[4])) << 40 | @as(u64, @intCast(buf[3])) << 48 | @as(u64, @intCast(buf[2])) << 56,
else => buf[1] & 127,
} + length_of_len + 2 + if (comptime EXPECT_MASK) 4 else 0; // +2 for header prefix, +4 for mask;
return .{ length_of_len, message_len };
}
// This is called after we've processed complete websocket messages (this
// only applies to websocket messages).
// There are three cases:
// 1 - We don't have any incomplete data (for a subsequent message) in buf.
// This is the easier to handle, we can set pos & len to 0.
// 2 - We have part of the next message, but we know it'll fit in the
// remaining buf. We don't need to do anything
// 3 - We have part of the next message, but either it won't fight into the
// remaining buffer, or we don't know (because we don't have enough
// of the header to tell the length). We need to "compact" the buffer
fn compact(self: *Self) void {
const pos = self.pos;
const len = self.len;
lp.assert(pos <= len, "Client.Reader.compact precondition", .{ .pos = pos, .len = len });
// how many (if any) partial bytes do we have
const partial_bytes = len - pos;
if (partial_bytes == 0) {
// We have no partial bytes. Setting these to 0 ensures that we
// get the best utilization of our buffer
self.pos = 0;
self.len = 0;
return;
}
const partial = self.buf[pos..len];
// If we have enough bytes of the next message to tell its length
// we'll be able to figure out whether we need to do anything or not.
if (extractLengths(partial)) |length_meta| {
const next_message_len = length_meta.@"1";
// if this isn't true, then we have a full message and it
// should have been processed.
lp.assert(pos <= len, "Client.Reader.compact postcondition", .{ .next_len = next_message_len, .partial = partial_bytes });
const missing_bytes = next_message_len - partial_bytes;
const free_space = self.buf.len - len;
if (missing_bytes < free_space) {
// we have enough space in our buffer, as is,
return;
}
}
// We're here because we either don't have enough bytes of the next
// message, or we know that it won't fit in our buffer as-is.
std.mem.copyForwards(u8, self.buf, partial);
self.pos = 0;
self.len = partial_bytes;
}
};
}
fn growBuffer(allocator: Allocator, buf: []u8, required_capacity: usize) ![]u8 {
// from std.ArrayList
var new_capacity = buf.len;
while (true) {
new_capacity +|= new_capacity / 2 + 8;
if (new_capacity >= required_capacity) break;
}
log.debug(.app, "CDP buffer growth", .{ .from = buf.len, .to = new_capacity });
if (allocator.resize(buf, new_capacity)) {
return buf.ptr[0..new_capacity];
}
const new_buffer = try allocator.alloc(u8, new_capacity);
@memcpy(new_buffer[0..buf.len], buf);
allocator.free(buf);
return new_buffer;
}
const Fragments = struct {
type: Message.Type,
message: std.ArrayList(u8),
};
const Message = struct {
type: Type,
data: []const u8,
cleanup_fragment: bool,
const Type = enum {
text,
binary,
close,
ping,
pong,
};
};
// These are the only websocket types that we're currently sending
const OpCode = enum(u8) {
text = 128 | 1,
close = 128 | 8,
pong = 128 | 10,
};
fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 {
// can't use buf[0..10] here, because the header length
// is variable. If it's just 2 bytes, for example, we need the
// framed message to be:
// h1, h2, data
// If we use buf[0..10], we'd get:
// h1, h2, 0, 0, 0, 0, 0, 0, 0, 0, data
var header_buf: [10]u8 = undefined;
// -10 because we reserved 10 bytes for the header above
const header = websocketHeader(&header_buf, .text, buf.items.len - 10);
const start = 10 - header.len;
const message = buf.items;
@memcpy(message[start..10], header);
return message[start..];
}
// makes the assumption that our caller reserved the first
// 10 bytes for the header
fn websocketHeader(buf: []u8, op_code: OpCode, payload_len: usize) []const u8 {
lp.assert(buf.len == 10, "Websocket.Header", .{ .len = buf.len });
const len = payload_len;
buf[0] = 128 | @intFromEnum(op_code); // fin | opcode
if (len <= 125) {
buf[1] = @intCast(len);
return buf[0..2];
}
if (len < 65536) {
buf[1] = 126;
buf[2] = @intCast((len >> 8) & 0xFF);
buf[3] = @intCast(len & 0xFF);
return buf[0..4];
}
buf[1] = 127;
buf[2] = 0;
buf[3] = 0;
buf[4] = 0;
buf[5] = 0;
buf[6] = @intCast((len >> 24) & 0xFF);
buf[7] = @intCast((len >> 16) & 0xFF);
buf[8] = @intCast((len >> 8) & 0xFF);
buf[9] = @intCast(len & 0xFF);
return buf[0..10];
}
// Utils // Utils
// -------- // --------
@@ -1139,48 +554,6 @@ fn buildJSONVersionResponse(
pub const timestamp = @import("datetime.zig").timestamp; pub const timestamp = @import("datetime.zig").timestamp;
// In-place string lowercase
fn toLower(str: []u8) []u8 {
for (str, 0..) |c, i| {
str[i] = std.ascii.toLower(c);
}
return str;
}
// Zig is in a weird backend transition right now. Need to determine if
// SIMD is even available.
const backend_supports_vectors = switch (builtin.zig_backend) {
.stage2_llvm, .stage2_c => true,
else => false,
};
// Websocket messages from client->server are masked using a 4 byte XOR mask
fn mask(m: []const u8, payload: []u8) void {
var data = payload;
if (!comptime backend_supports_vectors) return simpleMask(m, data);
const vector_size = std.simd.suggestVectorLength(u8) orelse @sizeOf(usize);
if (data.len >= vector_size) {
const mask_vector = std.simd.repeat(vector_size, @as(@Vector(4, u8), m[0..4].*));
while (data.len >= vector_size) {
const slice = data[0..vector_size];
const masked_data_slice: @Vector(vector_size, u8) = slice.*;
slice.* = masked_data_slice ^ mask_vector;
data = data[vector_size..];
}
}
simpleMask(m, data);
}
// Used when SIMD isn't available, or for any remaining part of the message
// which is too small to effectively use SIMD.
fn simpleMask(m: []const u8, payload: []u8) void {
for (payload, 0..) |b, i| {
payload[i] = b ^ m[i & 3];
}
}
const testing = std.testing; const testing = std.testing;
test "server: buildJSONVersionResponse" { test "server: buildJSONVersionResponse" {
const address = try net.Address.parseIp4("127.0.0.1", 9001); const address = try net.Address.parseIp4("127.0.0.1", 9001);
@@ -1391,22 +764,6 @@ test "Client: close message" {
); );
} }
test "server: mask" {
var buf: [4000]u8 = undefined;
const messages = [_][]const u8{ "1234", "1234" ** 99, "1234" ** 999 };
for (messages) |message| {
// we need the message to be mutable since mask operates in-place
const payload = buf[0..message.len];
@memcpy(payload, message);
mask(&.{ 1, 2, 200, 240 }, payload);
try testing.expectEqual(false, std.mem.eql(u8, payload, message));
mask(&.{ 1, 2, 200, 240 }, payload);
try testing.expectEqual(true, std.mem.eql(u8, payload, message));
}
}
test "server: 404" { test "server: 404" {
var c = try createTestClient(); var c = try createTestClient();
defer c.deinit(); defer c.deinit();
@@ -1542,7 +899,7 @@ fn createTestClient() !TestClient {
const TestClient = struct { const TestClient = struct {
stream: std.net.Stream, stream: std.net.Stream,
buf: [1024]u8 = undefined, buf: [1024]u8 = undefined,
reader: Reader(false), reader: Net.Reader(false),
fn deinit(self: *TestClient) void { fn deinit(self: *TestClient) void {
self.stream.close(); self.stream.close();
@@ -1609,7 +966,7 @@ const TestClient = struct {
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
} }
fn readWebsocketMessage(self: *TestClient) !?Message { fn readWebsocketMessage(self: *TestClient) !?Net.Message {
while (true) { while (true) {
const n = try self.stream.read(self.reader.readBuf()); const n = try self.stream.read(self.reader.readBuf());
if (n == 0) { if (n == 0) {

View File

@@ -87,19 +87,29 @@ pub fn closeSession(self: *Browser) void {
} }
} }
pub fn runMicrotasks(self: *const Browser) void { pub fn runMicrotasks(self: *Browser) void {
self.env.runMicrotasks(); self.env.runMicrotasks();
} }
pub fn runMacrotasks(self: *Browser) !?u64 { pub fn runMacrotasks(self: *Browser) !?u64 {
return try self.env.runMacrotasks(); const env = &self.env;
const time_to_next = try self.env.runMacrotasks();
env.pumpMessageLoop();
// either of the above could have queued more microtasks
env.runMicrotasks();
return time_to_next;
} }
pub fn runMessageLoop(self: *const Browser) void { pub fn hasBackgroundTasks(self: *Browser) bool {
while (self.env.pumpMessageLoop()) { return self.env.hasBackgroundTasks();
if (comptime IS_DEBUG) { }
log.debug(.browser, "pumpMessageLoop", .{}); pub fn waitForBackgroundTasks(self: *Browser) void {
} self.env.waitForBackgroundTasks();
} }
pub fn runIdleTasks(self: *const Browser) void {
self.env.runIdleTasks(); self.env.runIdleTasks();
} }

View File

@@ -56,7 +56,12 @@ pub const EventManager = @This();
page: *Page, page: *Page,
arena: Allocator, arena: Allocator,
// Used as an optimization in Page._documentIsComplete. If we know there are no
// 'load' listeners in the document, we can skip dispatching the per-resource
// 'load' event (e.g. amazon product page has no listener and ~350 resources)
has_dom_load_listener: bool,
listener_pool: std.heap.MemoryPool(Listener), listener_pool: std.heap.MemoryPool(Listener),
ignore_list: std.ArrayList(*Listener),
list_pool: std.heap.MemoryPool(std.DoublyLinkedList), list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
lookup: std.HashMapUnmanaged( lookup: std.HashMapUnmanaged(
EventKey, EventKey,
@@ -72,10 +77,12 @@ pub fn init(arena: Allocator, page: *Page) EventManager {
.page = page, .page = page,
.lookup = .{}, .lookup = .{},
.arena = arena, .arena = arena,
.ignore_list = .{},
.list_pool = .init(arena), .list_pool = .init(arena),
.listener_pool = .init(arena), .listener_pool = .init(arena),
.dispatch_depth = 0, .dispatch_depth = 0,
.deferred_removals = .{}, .deferred_removals = .{},
.has_dom_load_listener = false,
}; };
} }
@@ -106,6 +113,10 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
// Allocate the type string we'll use in both listener and key // Allocate the type string we'll use in both listener and key
const type_string = try String.init(self.arena, typ, .{}); const type_string = try String.init(self.arena, typ, .{});
if (type_string.eql(comptime .wrap("load")) and target._type == .node) {
self.has_dom_load_listener = true;
}
const gop = try self.lookup.getOrPut(self.arena, .{ const gop = try self.lookup.getOrPut(self.arena, .{
.type_string = type_string, .type_string = type_string,
.event_target = @intFromPtr(target), .event_target = @intFromPtr(target),
@@ -146,6 +157,11 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
}; };
// append the listener to the list of listeners for this target // append the listener to the list of listeners for this target
gop.value_ptr.*.append(&listener.node); gop.value_ptr.*.append(&listener.node);
// Track load listeners for script execution ignore list
if (type_string.eql(comptime .wrap("load"))) {
try self.ignore_list.append(self.arena, listener);
}
} }
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void { pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
@@ -158,6 +174,10 @@ pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callba
} }
} }
pub fn clearIgnoreList(self: *EventManager) void {
self.ignore_list.clearRetainingCapacity();
}
// Dispatching can be recursive from the compiler's point of view, so we need to // Dispatching can be recursive from the compiler's point of view, so we need to
// give it an explicit error set so that other parts of the code can use and // give it an explicit error set so that other parts of the code can use and
// inferred error. // inferred error.
@@ -169,7 +189,24 @@ const DispatchError = error{
ExecutionError, ExecutionError,
JsException, JsException,
}; };
pub const DispatchOpts = struct {
// A "load" event triggered by a script (in ScriptManager) should not trigger
// a "load" listener added within that script. Therefore, any "load" listener
// that we add go into an ignore list until after the script finishes executing.
// The ignore list is only checked when apply_ignore == true, which is only
// set by the ScriptManager when raising the script's "load" event.
apply_ignore: bool = false,
};
pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void { pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void {
return self.dispatchOpts(target, event, .{});
}
pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void {
event.acquireRef();
defer event.deinit(false, self.page);
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles }); log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
} }
@@ -186,7 +223,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat
}; };
switch (target._type) { switch (target._type) {
.node => |node| try self.dispatchNode(node, event, &was_handled), .node => |node| try self.dispatchNode(node, event, &was_handled, opts),
.xhr, .xhr,
.window, .window,
.abort_signal, .abort_signal,
@@ -197,13 +234,14 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat
.screen, .screen,
.screen_orientation, .screen_orientation,
.visual_viewport, .visual_viewport,
.file_reader,
.generic, .generic,
=> { => {
const list = self.lookup.get(.{ const list = self.lookup.get(.{
.event_target = @intFromPtr(target), .event_target = @intFromPtr(target),
.type_string = event._type_string, .type_string = event._type_string,
}) orelse return; }) orelse return;
try self.dispatchAll(list, target, event, &was_handled); try self.dispatchAll(list, target, event, &was_handled, opts);
}, },
} }
} }
@@ -218,6 +256,9 @@ const DispatchWithFunctionOptions = struct {
inject_target: bool = true, inject_target: bool = true,
}; };
pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *Event, function_: ?js.Function, comptime opts: DispatchWithFunctionOptions) !void { pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *Event, function_: ?js.Function, comptime opts: DispatchWithFunctionOptions) !void {
event.acquireRef();
defer event.deinit(false, self.page);
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.event, "dispatchWithFunction", .{ .type = event._type_string.str(), .context = opts.context, .has_function = function_ != null }); log.debug(.event, "dispatchWithFunction", .{ .type = event._type_string.str(), .context = opts.context, .has_function = function_ != null });
} }
@@ -249,10 +290,10 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
.event_target = @intFromPtr(target), .event_target = @intFromPtr(target),
.type_string = event._type_string, .type_string = event._type_string,
}) orelse return; }) orelse return;
try self.dispatchAll(list, target, event, &was_dispatched); try self.dispatchAll(list, target, event, &was_dispatched, .{});
} }
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void { fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool, comptime opts: DispatchOpts) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig"); const ShadowRoot = @import("webapi/ShadowRoot.zig");
const page = self.page; const page = self.page;
@@ -309,11 +350,14 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
node = n._parent; node = n._parent;
} }
// Even though the window isn't part of the DOM, events always propagate // Even though the window isn't part of the DOM, most events propagate
// through it in the capture phase (unless we stopped at a shadow boundary) // through it in the capture phase (unless we stopped at a shadow boundary)
if (path_len < path_buffer.len) { // The only explicit exception is "load"
path_buffer[path_len] = page.window.asEventTarget(); if (event._type_string.eql(comptime .wrap("load")) == false) {
path_len += 1; if (path_len < path_buffer.len) {
path_buffer[path_len] = page.window.asEventTarget();
path_len += 1;
}
} }
const path = path_buffer[0..path_len]; const path = path_buffer[0..path_len];
@@ -330,7 +374,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
.event_target = @intFromPtr(current_target), .event_target = @intFromPtr(current_target),
.type_string = event._type_string, .type_string = event._type_string,
})) |list| { })) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, true); try self.dispatchPhase(list, current_target, event, was_handled, comptime .init(true, opts));
} }
} }
@@ -364,7 +408,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
.type_string = event._type_string, .type_string = event._type_string,
.event_target = @intFromPtr(target_et), .event_target = @intFromPtr(target_et),
})) |list| { })) |list| {
try self.dispatchPhase(list, target_et, event, was_handled, null); try self.dispatchPhase(list, target_et, event, was_handled, comptime .init(null, opts));
if (event._stop_propagation) { if (event._stop_propagation) {
return; return;
} }
@@ -381,13 +425,25 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
.type_string = event._type_string, .type_string = event._type_string,
.event_target = @intFromPtr(current_target), .event_target = @intFromPtr(current_target),
})) |list| { })) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, false); try self.dispatchPhase(list, current_target, event, was_handled, comptime .init(false, opts));
} }
} }
} }
} }
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime capture_only: ?bool) !void { const DispatchPhaseOpts = struct {
capture_only: ?bool = null,
apply_ignore: bool = false,
fn init(capture_only: ?bool, opts: DispatchOpts) DispatchPhaseOpts {
return .{
.capture_only = capture_only,
.apply_ignore = opts.apply_ignore,
};
}
};
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime opts: DispatchPhaseOpts) !void {
const page = self.page; const page = self.page;
// Track dispatch depth for deferred removal // Track dispatch depth for deferred removal
@@ -413,7 +469,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
// Iterate through the list, stopping after we've encountered the last_listener // Iterate through the list, stopping after we've encountered the last_listener
var node = list.first; var node = list.first;
var is_done = false; var is_done = false;
while (node) |n| { node_loop: while (node) |n| {
if (is_done) { if (is_done) {
break; break;
} }
@@ -423,7 +479,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
node = n.next; node = n.next;
// Skip non-matching listeners // Skip non-matching listeners
if (comptime capture_only) |capture| { if (comptime opts.capture_only) |capture| {
if (listener.capture != capture) { if (listener.capture != capture) {
continue; continue;
} }
@@ -442,6 +498,14 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
} }
} }
if (comptime opts.apply_ignore) {
for (self.ignore_list.items) |ignored| {
if (ignored == listener) {
continue :node_loop;
}
}
}
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them // Remove "once" listeners BEFORE calling them so nested dispatches don't see them
if (listener.once) { if (listener.once) {
self.removeListener(list, listener); self.removeListener(list, listener);
@@ -486,8 +550,8 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
} }
// Non-Node dispatching (XHR, Window without propagation) // Non-Node dispatching (XHR, Window without propagation)
fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool) !void { fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime opts: DispatchOpts) !void {
return self.dispatchPhase(list, current_target, event, was_handled, null); return self.dispatchPhase(list, current_target, event, was_handled, comptime .init(null, opts));
} }
fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global { fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global {
@@ -757,7 +821,6 @@ const ActivationState = struct {
.bubbles = true, .bubbles = true,
.cancelable = false, .cancelable = false,
}, page); }, page);
defer if (!event._v8_handoff) event.deinit(false);
const target = input.asElement().asEventTarget(); const target = input.asElement().asEventTarget();
try page._event_manager.dispatch(target, event); try page._event_manager.dispatch(target, event);

View File

@@ -42,12 +42,96 @@ const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;
const assert = std.debug.assert; const assert = std.debug.assert;
// Shared across all frames of a Page.
const Factory = @This(); const Factory = @This();
_page: *Page,
_arena: Allocator, _arena: Allocator,
_slab: SlabAllocator, _slab: SlabAllocator,
pub fn init(arena: Allocator) !*Factory {
const self = try arena.create(Factory);
self.* = .{
._arena = arena,
._slab = SlabAllocator.init(arena, 128),
};
return self;
}
// this is a root object
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
return self.eventTargetWithAllocator(self._slab.allocator(), child);
}
pub fn eventTargetWithAllocator(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ EventTarget, @TypeOf(child) },
).allocate(allocator);
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(EventTarget.Type, chain.get(1)),
};
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget {
const allocator = self._slab.allocator();
const et = try allocator.create(EventTarget);
et.* = .{ ._type = unionInit(EventTarget.Type, child) };
return et;
}
// this is a root object
pub fn event(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn uiEvent(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(_: *const Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
fn PrototypeChain(comptime types: []const type) type { fn PrototypeChain(comptime types: []const type) type {
return struct { return struct {
const Self = @This(); const Self = @This();
@@ -151,94 +235,14 @@ fn AutoPrototypeChain(comptime types: []const type) type {
}; };
} }
pub fn init(arena: Allocator, page: *Page) Factory { fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
return .{
._page = page,
._arena = arena,
._slab = SlabAllocator.init(arena, 128),
};
}
// this is a root object
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
const chain = try PrototypeChain(
&.{ EventTarget, @TypeOf(child) },
).allocate(allocator);
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(EventTarget.Type, chain.get(1)),
};
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget {
const allocator = self._slab.allocator();
const et = try allocator.create(EventTarget);
et.* = .{ ._type = unionInit(EventTarget.Type, child) };
return et;
}
// this is a root object
pub fn event(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn uiEvent(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(self: *Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
fn eventInit(self: *const Factory, arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this) // Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic); const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2; const time_stamp = (raw_timestamp / 2) * 2;
return .{ return .{
._rc = 0,
._arena = arena, ._arena = arena,
._page = self._page,
._type = unionInit(Event.Type, value), ._type = unionInit(Event.Type, value),
._type_string = typ, ._type_string = typ,
._time_stamp = time_stamp, ._time_stamp = time_stamp,
@@ -384,7 +388,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
} }
if (comptime @hasField(S, "_proto")) { if (comptime @hasField(S, "_proto")) {
self.destroyChain(value, true, 0, std.mem.Alignment.@"1"); self.destroyChain(value, 0, std.mem.Alignment.@"1");
} else { } else {
self.destroyStandalone(value); self.destroyStandalone(value);
} }
@@ -398,7 +402,6 @@ pub fn destroyStandalone(self: *Factory, value: anytype) void {
fn destroyChain( fn destroyChain(
self: *Factory, self: *Factory,
value: anytype, value: anytype,
comptime first: bool,
old_size: usize, old_size: usize,
old_align: std.mem.Alignment, old_align: std.mem.Alignment,
) void { ) void {
@@ -410,23 +413,8 @@ fn destroyChain(
const new_size = current_size + @sizeOf(S); const new_size = current_size + @sizeOf(S);
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S)); const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
// This is initially called from a deinit. We don't want to call that
// same deinit. So when this is the first time destroyChain is called
// we don't call deinit (because we're in that deinit)
if (!comptime first) {
// But if it isn't the first time
if (@hasDecl(S, "deinit")) {
// And it has a deinit, we'll call it
switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) {
1 => value.deinit(),
2 => value.deinit(self._page),
else => @compileLog(@typeName(S) ++ " has an invalid deinit function"),
}
}
}
if (@hasField(S, "_proto")) { if (@hasField(S, "_proto")) {
self.destroyChain(value._proto, false, new_size, new_align); self.destroyChain(value._proto, new_size, new_align);
} else { } else {
// no proto so this is the head of the chain. // no proto so this is the head of the chain.
// we use this as the ptr to the start of the chain. // we use this as the ptr to the start of the chain.

View File

@@ -24,10 +24,11 @@ params: []const u8 = "",
// IANA defines max. charset value length as 40. // IANA defines max. charset value length as 40.
// We keep 41 for null-termination since HTML parser expects in this format. // We keep 41 for null-termination since HTML parser expects in this format.
charset: [41]u8 = default_charset, charset: [41]u8 = default_charset,
charset_len: usize = 5, charset_len: usize = default_charset_len,
/// String "UTF-8" continued by null characters. /// String "UTF-8" continued by null characters.
pub const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
const default_charset_len = 5;
/// Mime with unknown Content-Type, empty params and empty charset. /// Mime with unknown Content-Type, empty params and empty charset.
pub const unknown = Mime{ .content_type = .{ .unknown = {} } }; pub const unknown = Mime{ .content_type = .{ .unknown = {} } };
@@ -127,17 +128,17 @@ pub fn parse(input: []u8) !Mime {
const params = trimLeft(normalized[type_len..]); const params = trimLeft(normalized[type_len..]);
var charset: [41]u8 = undefined; var charset: [41]u8 = default_charset;
var charset_len: usize = undefined; var charset_len: usize = default_charset_len;
var it = std.mem.splitScalar(u8, params, ';'); var it = std.mem.splitScalar(u8, params, ';');
while (it.next()) |attr| { while (it.next()) |attr| {
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse return error.Invalid; const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse continue;
const name = trimLeft(attr[0..i]); const name = trimLeft(attr[0..i]);
const value = trimRight(attr[i + 1 ..]); const value = trimRight(attr[i + 1 ..]);
if (value.len == 0) { if (value.len == 0) {
return error.Invalid; continue;
} }
const attribute_name = std.meta.stringToEnum(enum { const attribute_name = std.meta.stringToEnum(enum {
@@ -150,7 +151,7 @@ pub fn parse(input: []u8) !Mime {
break; break;
} }
const attribute_value = try parseCharset(value); const attribute_value = parseCharset(value) catch continue;
@memcpy(charset[0..attribute_value.len], attribute_value); @memcpy(charset[0..attribute_value.len], attribute_value);
// Null-terminate right after attribute value. // Null-terminate right after attribute value.
charset[attribute_value.len] = 0; charset[attribute_value.len] = 0;
@@ -334,6 +335,19 @@ test "Mime: invalid" {
"text/ html", "text/ html",
"text / html", "text / html",
"text/html other", "text/html other",
};
for (invalids) |invalid| {
const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
try testing.expectError(error.Invalid, Mime.parse(mutable_input));
}
}
test "Mime: malformed parameters are ignored" {
defer testing.reset();
// These should all parse successfully as text/html with malformed params ignored
const valid_with_malformed_params = [_][]const u8{
"text/html; x", "text/html; x",
"text/html; x=", "text/html; x=",
"text/html; x= ", "text/html; x= ",
@@ -342,11 +356,13 @@ test "Mime: invalid" {
"text/html; charset=\"\"", "text/html; charset=\"\"",
"text/html; charset=\"", "text/html; charset=\"",
"text/html; charset=\"\\", "text/html; charset=\"\\",
"text/html;\"",
}; };
for (invalids) |invalid| { for (valid_with_malformed_params) |input| {
const mutable_input = try testing.arena_allocator.dupe(u8, invalid); const mutable_input = try testing.arena_allocator.dupe(u8, input);
try testing.expectError(error.Invalid, Mime.parse(mutable_input)); const mime = try Mime.parse(mutable_input);
try testing.expectEqual(.text_html, std.meta.activeTag(mime.content_type));
} }
} }
@@ -435,6 +451,12 @@ test "Mime: parse charset" {
.charset = "custom-non-standard-charset-value", .charset = "custom-non-standard-charset-value",
.params = "charset=\"custom-non-standard-charset-value\"", .params = "charset=\"custom-non-standard-charset-value\"",
}, "text/xml;charset=\"custom-non-standard-charset-value\""); }, "text/xml;charset=\"custom-non-standard-charset-value\"");
try expect(.{
.content_type = .{ .text_html = {} },
.charset = "UTF-8",
.params = "x=\"",
}, "text/html;x=\"");
} }
test "Mime: isHTML" { test "Mime: isHTML" {

View File

@@ -63,15 +63,14 @@ const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const Http = App.Http; const Http = App.Http;
const Net = @import("../Net.zig");
const ArenaPool = App.ArenaPool; const ArenaPool = App.ArenaPool;
const timestamp = @import("../datetime.zig").timestamp; const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const WebApiURL = @import("webapi/URL.zig"); const WebApiURL = @import("webapi/URL.zig");
const global_event_handlers = @import("webapi/global_event_handlers.zig"); const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
const GlobalEventHandlersLookup = global_event_handlers.Lookup;
const GlobalEventHandler = global_event_handlers.Handler;
var default_url = WebApiURL{ ._raw = "about:blank" }; var default_url = WebApiURL{ ._raw = "about:blank" };
pub var default_location: Location = Location{ ._url = &default_url }; pub var default_location: Location = Location{ ._url = &default_url };
@@ -139,7 +138,7 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{},
/// `load` events that'll be fired before window's `load` event. /// `load` events that'll be fired before window's `load` event.
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it. /// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
_to_load: std.ArrayList(*Element) = .{}, _to_load: std.ArrayList(*Element.Html) = .{},
_script_manager: ScriptManager, _script_manager: ScriptManager,
@@ -175,7 +174,7 @@ _upgrading_element: ?*Node = null,
_undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{}, _undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{},
// for heap allocations and managing WebAPI objects // for heap allocations and managing WebAPI objects
_factory: Factory, _factory: *Factory,
_load_state: LoadState = .waiting, _load_state: LoadState = .waiting,
@@ -249,14 +248,15 @@ pub fn init(self: *Page, id: u32, session: *Session, parent: ?*Page) !void {
} }
const browser = session.browser; const browser = session.browser;
const arena_pool = browser.arena_pool; const arena_pool = browser.arena_pool;
const page_arena = try arena_pool.acquire();
errdefer arena_pool.release(page_arena); const page_arena = if (parent) |p| p.arena else try arena_pool.acquire();
errdefer if (parent == null) arena_pool.release(page_arena);
var factory = if (parent) |p| p._factory else try Factory.init(page_arena);
const call_arena = try arena_pool.acquire(); const call_arena = try arena_pool.acquire();
errdefer arena_pool.release(call_arena); errdefer arena_pool.release(call_arena);
var factory = Factory.init(page_arena, self);
const document = (try factory.document(Node.Document.HTMLDocument{ const document = (try factory.document(Node.Document.HTMLDocument{
._proto = undefined, ._proto = undefined,
})).asDocument(); })).asDocument();
@@ -309,15 +309,17 @@ pub fn init(self: *Page, id: u32, session: *Session, parent: ?*Page) !void {
self.js = try browser.env.createContext(self); self.js = try browser.env.createContext(self);
errdefer self.js.deinit(); errdefer self.js.deinit();
document._page = self;
if (comptime builtin.is_test == false) { if (comptime builtin.is_test == false) {
// HTML test runner manually calls these as necessary // HTML test runner manually calls these as necessary
try self.js.scheduler.add(session.browser, struct { try self.js.scheduler.add(session.browser, struct {
fn runMessageLoop(ctx: *anyopaque) !?u32 { fn runIdleTasks(ctx: *anyopaque) !?u32 {
const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx)); const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
b.runMessageLoop(); b.runIdleTasks();
return 250; return 200;
} }
}.runMessageLoop, 250, .{ .name = "page.messageLoop" }); }.runIdleTasks, 200, .{ .name = "page.runIdleTasks", .low_priority = true });
} }
} }
@@ -351,13 +353,16 @@ pub fn deinit(self: *Page) void {
var it = self._arena_pool_leak_track.valueIterator(); var it = self._arena_pool_leak_track.valueIterator();
while (it.next()) |value_ptr| { while (it.next()) |value_ptr| {
if (value_ptr.count > 0) { if (value_ptr.count > 0) {
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner, .type = self._type }); log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner, .type = self._type, .url = self.url });
} }
} }
} }
self.arena_pool.release(self.call_arena); self.arena_pool.release(self.call_arena);
self.arena_pool.release(self.arena);
if (self.parent == null) {
self.arena_pool.release(self.arena);
}
} }
pub fn base(self: *const Page) [:0]const u8 { pub fn base(self: *const Page) [:0]const u8 {
@@ -420,7 +425,7 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?; const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
if (found.count != 1) { if (found.count != 1) {
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type }); log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type, .url = self.url });
return; return;
} }
found.count = 0; found.count = 0;
@@ -564,7 +569,7 @@ fn scheduleNavigationWithArena(self: *Page, arena: Allocator, request_url: []con
arena, arena,
self.base(), self.base(),
request_url, request_url,
.{ .always_dupe = true }, .{ .always_dupe = true, .encode = true },
); );
const session = self._session; const session = self._session;
@@ -637,13 +642,12 @@ pub fn documentIsLoaded(self: *Page) void {
self._load_state = .load; self._load_state = .load;
self.document._ready_state = .interactive; self.document._ready_state = .interactive;
self._documentIsLoaded() catch |err| { self._documentIsLoaded() catch |err| {
log.err(.page, "document is loaded", .{ .err = err, .type = self._type }); log.err(.page, "document is loaded", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
pub fn _documentIsLoaded(self: *Page) !void { pub fn _documentIsLoaded(self: *Page) !void {
const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self); const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self);
defer if (!event._v8_handoff) event.deinit(false);
try self._event_manager.dispatch( try self._event_manager.dispatch(
self.document.asEventTarget(), self.document.asEventTarget(),
event, event,
@@ -661,10 +665,9 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *Element.Html.IFrame) void {
defer ls.deinit(); defer ls.deinit();
const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| { const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| {
log.err(.page, "iframe event init", .{ .err = err }); log.err(.page, "iframe event init", .{ .err = err, .url = iframe._src });
break :blk; break :blk;
}; };
defer if (!event._v8_handoff) event.deinit(false);
self._event_manager.dispatch(iframe.asNode().asEventTarget(), event) catch |err| { self._event_manager.dispatch(iframe.asNode().asEventTarget(), event) catch |err| {
log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src }); log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src });
}; };
@@ -701,7 +704,7 @@ pub fn documentIsComplete(self: *Page) void {
self._load_state = .complete; self._load_state = .complete;
self._documentIsComplete() catch |err| { self._documentIsComplete() catch |err| {
log.err(.page, "document is complete", .{ .err = err, .type = self._type }); log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
}; };
if (IS_DEBUG) { if (IS_DEBUG) {
@@ -720,23 +723,15 @@ pub fn documentIsComplete(self: *Page) void {
fn _documentIsComplete(self: *Page) !void { fn _documentIsComplete(self: *Page) !void {
self.document._ready_state = .complete; self.document._ready_state = .complete;
// Run load events before window.load.
try self.dispatchLoad();
var ls: JS.Local.Scope = undefined; var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls); self.js.localScope(&ls);
defer ls.deinit(); defer ls.deinit();
// Dispatch `_to_load` events before window.load.
for (self._to_load.items) |element| {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
defer if (!event._v8_handoff) event.deinit(false);
try self._event_manager.dispatch(element.asEventTarget(), event);
}
// `_to_load` can be cleaned here.
self._to_load.clearAndFree(self.arena);
// Dispatch window.load event. // Dispatch window.load event.
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
defer if (!event._v8_handoff) event.deinit(false);
// This event is weird, it's dispatched directly on the window, but // This event is weird, it's dispatched directly on the window, but
// with the document as the target. // with the document as the target.
event._target = self.document.asEventTarget(); event._target = self.document.asEventTarget();
@@ -748,7 +743,6 @@ fn _documentIsComplete(self: *Page) !void {
); );
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent(); const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
defer if (!pageshow_event._v8_handoff) pageshow_event.deinit(false);
try self._event_manager.dispatchWithFunction( try self._event_manager.dispatchWithFunction(
self.window.asEventTarget(), self.window.asEventTarget(),
pageshow_event, pageshow_event,
@@ -806,7 +800,7 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
} orelse .unknown; } orelse .unknown;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len, .type = self._type }); log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len, .type = self._type, .url = self.url });
} }
switch (mime.content_type) { switch (mime.content_type) {
@@ -853,7 +847,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
var self: *Page = @ptrCast(@alignCast(ctx)); var self: *Page = @ptrCast(@alignCast(ctx));
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.page, "navigate done", .{ .type = self._type }); log.debug(.page, "navigate done", .{ .type = self._type, .url = self.url });
} }
//We need to handle different navigation types differently. //We need to handle different navigation types differently.
@@ -872,11 +866,6 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
.html => |buf| { .html => |buf| {
parser.parse(buf.items); parser.parse(buf.items);
self._script_manager.staticScriptsDone(); self._script_manager.staticScriptsDone();
if (self._script_manager.isDone()) {
// No scripts, or just inline scripts that were already processed
// we need to trigger this ourselves
self.documentIsComplete();
}
self._parse_state = .complete; self._parse_state = .complete;
}, },
.text => |*buf| { .text => |*buf| {
@@ -931,248 +920,17 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void {
var self: *Page = @ptrCast(@alignCast(ctx)); var self: *Page = @ptrCast(@alignCast(ctx));
log.err(.page, "navigate failed", .{ .err = err, .type = self._type }); log.err(.page, "navigate failed", .{ .err = err, .type = self._type, .url = self.url });
self._parse_state = .{ .err = err }; self._parse_state = .{ .err = err };
// In case of error, we want to complete the page with a custom HTML // In case of error, we want to complete the page with a custom HTML
// containing the error. // containing the error.
pageDoneCallback(ctx) catch |e| { pageDoneCallback(ctx) catch |e| {
log.err(.browser, "pageErrorCallback", .{ .err = e, .type = self._type }); log.err(.browser, "pageErrorCallback", .{ .err = e, .type = self._type, .url = self.url });
return; return;
}; };
} }
pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult {
return self._wait(wait_ms) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)
else => {
// There may be errors from the http/client or ScriptManager
// that we should not treat as an error like this. Will need
// to run this through more real-world sites and see if we need
// to expand the switch (err) to have more customized logs for
// specific messages.
log.err(.browser, "page wait", .{ .err = err, .type = self._type });
},
}
return .done;
};
}
fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult {
if (comptime IS_DEBUG) {
std.debug.assert(self._type == .root);
}
var timer = try std.time.Timer.start();
var ms_remaining = wait_ms;
const browser = self._session.browser;
var http_client = browser.http_client;
// I'd like the page to know NOTHING about cdp_socket / CDP, but the
// fact is that the behavior of wait changes depending on whether or
// not we're using CDP.
// If we aren't using CDP, as soon as we think there's nothing left
// to do, we can exit - we'de done.
// But if we are using CDP, we should wait for the whole `wait_ms`
// because the http_click.tick() also monitors the CDP socket. And while
// we could let CDP poll http (like it does for HTTP requests), the fact
// is that we know more about the timing of stuff (e.g. how long to
// poll/sleep) in the page.
const exit_when_done = http_client.cdp_client == null;
// for debugging
// defer self.printWaitAnalysis();
while (true) {
switch (self._parse_state) {
.pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and exit_when_done) {
// haven't started navigating, I guess.
return .done;
}
// Either we have active http connections, or we're in CDP
// mode with an extra socket. Either way, we're waiting
// for http traffic
if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) {
// exit_when_done is explicitly set when there isn't
// an extra socket, so it should not be possibl to
// get an cdp_socket message when exit_when_done
// is true.
if (IS_DEBUG) {
std.debug.assert(exit_when_done == false);
}
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
},
.html, .complete => {
if (self._queued_navigation != null) {
return .done;
}
// The HTML page was parsed. We now either have JS scripts to
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
const ms_to_next_task = try browser.runMacrotasks();
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (self._notified_network_almost_idle.check(total_network_activity <= 2)) {
self.notifyNetworkAlmostIdle();
}
if (self._notified_network_idle.check(total_network_activity == 0)) {
self.notifyNetworkIdle();
}
if (http_active == 0 and exit_when_done) {
// we don't need to consider http_client.intercepted here
// because exit_when_done is true, and that can only be
// the case when interception isn't possible.
if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0);
}
const ms = ms_to_next_task orelse blk: {
if (wait_ms - ms_remaining < 100) {
if (comptime builtin.is_test) {
return .done;
}
// Look, we want to exit ASAP, but we don't want
// to exit so fast that we've run none of the
// background jobs.
break :blk 50;
}
// No http transfers, no cdp extra socket, no
// scheduled tasks, we're done.
return .done;
};
if (ms > ms_remaining) {
// Same as above, except we have a scheduled task,
// it just happens to be too far into the future
// compared to how long we were told to wait.
return .done;
}
// We have a task to run in the not-so-distant future.
// You might think we can just sleep until that task is
// ready, but we should continue to run lowPriority tasks
// in the meantime, and that could unblock things. So
// we'll just sleep for a bit, and then restart our wait
// loop to see if anything new can be processed.
std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20))));
} else {
// We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run lowPriority tasks, so we
// minimize how long we'll poll for network I/O.
const ms_to_wait = @min(200, @min(ms_remaining, ms_to_next_task orelse 200));
if (try http_client.tick(ms_to_wait) == .cdp_socket) {
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
}
},
.err => |err| {
self._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => {
if (exit_when_done) {
return .done;
}
// we _could_ http_client.tick(ms_to_wait), but this has
// the same result, and I feel is more correct.
return .no_page;
},
}
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
}
}
fn printWaitAnalysis(self: *Page) void {
std.debug.print("load_state: {s}\n", .{@tagName(self._load_state)});
std.debug.print("parse_state: {s}\n", .{@tagName(std.meta.activeTag(self._parse_state))});
{
std.debug.print("\nactive requests: {d}\n", .{self._session.browser.http_client.active});
var n_ = self._session.browser.http_client.handles.in_use.first;
while (n_) |n| {
const handle: *Http.Client.Handle = @fieldParentPtr("node", n);
const transfer = Http.Transfer.fromEasy(handle.conn.easy) catch |err| {
std.debug.print(" - failed to load transfer: {any}\n", .{err});
break;
};
std.debug.print(" - {f}\n", .{transfer});
n_ = n.next;
}
}
{
std.debug.print("\nqueued requests: {d}\n", .{self._session.browser.http_client.queue.len()});
var n_ = self._session.browser.http_client.queue.first;
while (n_) |n| {
const transfer: *Http.Transfer = @fieldParentPtr("_node", n);
std.debug.print(" - {f}\n", .{transfer});
n_ = n.next;
}
}
{
std.debug.print("\ndeferreds: {d}\n", .{self._script_manager.defer_scripts.len()});
var n_ = self._script_manager.defer_scripts.first;
while (n_) |n| {
const script: *ScriptManager.Script = @fieldParentPtr("node", n);
std.debug.print(" - {s} complete: {any}\n", .{ script.url, script.complete });
n_ = n.next;
}
}
{
std.debug.print("\nasyncs: {d}\n", .{self._script_manager.async_scripts.len()});
}
{
std.debug.print("\nasyncs ready: {d}\n", .{self._script_manager.ready_scripts.len()});
var n_ = self._script_manager.ready_scripts.first;
while (n_) |n| {
const script: *ScriptManager.Script = @fieldParentPtr("node", n);
std.debug.print(" - {s} complete: {any}\n", .{ script.url, script.complete });
n_ = n.next;
}
}
const now = milliTimestamp(.monotonic);
{
std.debug.print("\nhigh_priority schedule: {d}\n", .{self.js.scheduler.high_priority.count()});
var it = self.js.scheduler.high_priority.iterator();
while (it.next()) |task| {
std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now });
}
}
{
std.debug.print("\nlow_priority schedule: {d}\n", .{self.js.scheduler.low_priority.count()});
var it = self.js.scheduler.low_priority.iterator();
while (it.next()) |task| {
std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now });
}
}
}
pub fn isGoingAway(self: *const Page) bool { pub fn isGoingAway(self: *const Page) bool {
return self._queued_navigation != null; return self._queued_navigation != null;
} }
@@ -1186,6 +944,7 @@ pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Ele
self._script_manager.addFromElement(from_parser, script, "parsing") catch |err| { self._script_manager.addFromElement(from_parser, script, "parsing") catch |err| {
log.err(.page, "page.scriptAddedCallback", .{ log.err(.page, "page.scriptAddedCallback", .{
.err = err, .err = err,
.url = self.url,
.src = script.asElement().getAttributeSafe(comptime .wrap("src")), .src = script.asElement().getAttributeSafe(comptime .wrap("src")),
.type = self._type, .type = self._type,
}); });
@@ -1201,7 +960,7 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void {
return; return;
} }
const src = try iframe.getSrc(self); const src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse return;
if (src.len == 0) { if (src.len == 0) {
return; return;
} }
@@ -1223,8 +982,16 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void {
.timestamp = timestamp(.monotonic), .timestamp = timestamp(.monotonic),
}); });
page_frame.navigate(src, .{ .reason = .initialFrameNavigation }) catch |err| { // navigate will dupe the url
log.warn(.page, "iframe navigate failure", .{ .url = src, .err = err }); const url = try URL.resolve(
self.call_arena,
self.base(),
src,
.{ .encode = true },
);
page_frame.navigate(url, .{ .reason = .initialFrameNavigation }) catch |err| {
log.warn(.page, "iframe navigate failure", .{ .url = url, .err = err });
self._pending_loads -= 1; self._pending_loads -= 1;
iframe._content_window = null; iframe._content_window = null;
page_frame.deinit(); page_frame.deinit();
@@ -1272,7 +1039,7 @@ pub fn domChanged(self: *Page) void {
self._intersection_check_scheduled = true; self._intersection_check_scheduled = true;
self.js.queueIntersectionChecks() catch |err| { self.js.queueIntersectionChecks() catch |err| {
log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type }); log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
@@ -1351,29 +1118,6 @@ pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Elemen
return null; return null;
} }
/// Sets an inline event listener (`onload`, `onclick`, `onwheel` etc.);
/// overrides the listener if there's already one.
pub fn setAttrListener(
self: *Page,
element: *Element,
listener_type: GlobalEventHandler,
listener_callback: JS.Function.Global,
) !void {
if (comptime IS_DEBUG) {
log.debug(.event, "Page.setAttrListener", .{
.element = element,
.listener_type = listener_type,
.type = self._type,
});
}
const gop = try self._element_attr_listeners.getOrPut(self.arena, .{
.target = element.asEventTarget(),
.handler = listener_type,
});
gop.value_ptr.* = listener_callback;
}
pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void { pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void {
return self._performance_observers.append(self.arena, observer); return self._performance_observers.append(self.arena, observer);
} }
@@ -1393,7 +1137,7 @@ pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void
for (self._performance_observers.items) |observer| { for (self._performance_observers.items) |observer| {
if (observer.interested(entry)) { if (observer.interested(entry)) {
observer._entries.append(self.arena, entry) catch |err| { observer._entries.append(self.arena, entry) catch |err| {
log.err(.page, "notifyPerformanceObservers", .{ .err = err, .type = self._type }); log.err(.page, "notifyPerformanceObservers", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
} }
@@ -1458,6 +1202,18 @@ pub fn checkIntersections(self: *Page) !void {
} }
} }
pub fn dispatchLoad(self: *Page) !void {
const has_dom_load_listener = self._event_manager.has_dom_load_listener;
for (self._to_load.items) |html_element| {
if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
try self._event_manager.dispatch(html_element.asEventTarget(), event);
}
}
// We drained everything.
self._to_load.clearRetainingCapacity();
}
pub fn scheduleMutationDelivery(self: *Page) !void { pub fn scheduleMutationDelivery(self: *Page) !void {
if (self._mutation_delivery_scheduled) { if (self._mutation_delivery_scheduled) {
return; return;
@@ -1488,7 +1244,7 @@ pub fn performScheduledIntersectionChecks(self: *Page) void {
} }
self._intersection_check_scheduled = false; self._intersection_check_scheduled = false;
self.checkIntersections() catch |err| { self.checkIntersections() catch |err| {
log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type }); log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
@@ -1504,7 +1260,7 @@ pub fn deliverIntersections(self: *Page) void {
i -= 1; i -= 1;
const observer = self._intersection_observers.items[i]; const observer = self._intersection_observers.items[i];
observer.deliverEntries(self) catch |err| { observer.deliverEntries(self) catch |err| {
log.err(.page, "page.deliverIntersections", .{ .err = err, .type = self._type }); log.err(.page, "page.deliverIntersections", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
} }
@@ -1522,7 +1278,7 @@ pub fn deliverMutations(self: *Page) void {
}; };
if (self._mutation_delivery_depth > 100) { if (self._mutation_delivery_depth > 100) {
log.err(.page, "page.MutationLimit", .{ .type = self._type }); log.err(.page, "page.MutationLimit", .{ .type = self._type, .url = self.url });
self._mutation_delivery_depth = 0; self._mutation_delivery_depth = 0;
return; return;
} }
@@ -1531,7 +1287,7 @@ pub fn deliverMutations(self: *Page) void {
while (it) |node| : (it = node.next) { while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node); const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.deliverRecords(self) catch |err| { observer.deliverRecords(self) catch |err| {
log.err(.page, "page.deliverMutations", .{ .err = err, .type = self._type }); log.err(.page, "page.deliverMutations", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
} }
@@ -1549,7 +1305,7 @@ pub fn deliverSlotchangeEvents(self: *Page) void {
var i: usize = 0; var i: usize = 0;
var slots = self.call_arena.alloc(*Element.Html.Slot, pending) catch |err| { var slots = self.call_arena.alloc(*Element.Html.Slot, pending) catch |err| {
log.err(.page, "deliverSlotchange.append", .{ .err = err, .type = self._type }); log.err(.page, "deliverSlotchange.append", .{ .err = err, .type = self._type, .url = self.url });
return; return;
}; };
@@ -1562,14 +1318,12 @@ pub fn deliverSlotchangeEvents(self: *Page) void {
for (slots) |slot| { for (slots) |slot| {
const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| { const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| {
log.err(.page, "deliverSlotchange.init", .{ .err = err, .type = self._type }); log.err(.page, "deliverSlotchange.init", .{ .err = err, .type = self._type, .url = self.url });
continue; continue;
}; };
defer if (!event._v8_handoff) event.deinit(false);
const target = slot.asNode().asEventTarget(); const target = slot.asNode().asEventTarget();
_ = target.dispatchEvent(event, self) catch |err| { _ = target.dispatchEvent(event, self) catch |err| {
log.err(.page, "deliverSlotchange.dispatch", .{ .err = err, .type = self._type }); log.err(.page, "deliverSlotchange.dispatch", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
} }
@@ -1601,10 +1355,8 @@ pub fn appendNew(self: *Page, parent: *Node, child: Node.NodeOrText) !void {
if (parent.lastChild()) |sibling| { if (parent.lastChild()) |sibling| {
if (sibling.is(CData.Text)) |tn| { if (sibling.is(CData.Text)) |tn| {
const cdata = tn._proto; const cdata = tn._proto;
const existing = cdata.getData(); const existing = cdata.getData().str();
// @metric cdata._data = try String.concat(self.arena, &.{ existing, txt });
// Inefficient, but we don't expect this to happen often.
cdata._data = try std.mem.concat(self.arena, u8, &.{ existing, txt });
return; return;
} }
} }
@@ -1624,7 +1376,7 @@ pub fn appendNew(self: *Page, parent: *Node, child: Node.NodeOrText) !void {
// called from the parser when the node and all its children have been added // called from the parser when the node and all its children have been added
pub fn nodeComplete(self: *Page, node: *Node) !void { pub fn nodeComplete(self: *Page, node: *Node) !void {
Node.Build.call(node, "complete", .{ node, self }) catch |err| { Node.Build.call(node, "complete", .{ node, self }) catch |err| {
log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type }); log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type, .url = self.url });
return err; return err;
}; };
return self.nodeIsReady(true, node); return self.nodeIsReady(true, node);
@@ -2323,7 +2075,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
var caught: JS.TryCatch.Caught = undefined; var caught: JS.TryCatch.Caught = undefined;
_ = ls.toLocal(def.constructor).newInstance(&caught) catch |err| { _ = ls.toLocal(def.constructor).newInstance(&caught) catch |err| {
log.warn(.js, "custom element constructor", .{ .name = name, .err = err, .caught = caught, .type = self._type }); log.warn(.js, "custom element constructor", .{ .name = name, .err = err, .caught = caught, .type = self._type, .url = self.url });
return node; return node;
}; };
@@ -2381,7 +2133,7 @@ fn createHtmlElementT(self: *Page, comptime E: type, namespace: Element.Namespac
const node = element.asNode(); const node = element.asNode();
if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) { if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) {
@call(.auto, @field(E.Build, "created"), .{ node, self }) catch |err| { @call(.auto, @field(E.Build, "created"), .{ node, self }) catch |err| {
log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type }); log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type, .url = self.url });
return err; return err;
}; };
} }
@@ -2434,28 +2186,24 @@ fn populateElementAttributes(self: *Page, element: *Element, list: anytype) !voi
} }
pub fn createTextNode(self: *Page, text: []const u8) !*Node { pub fn createTextNode(self: *Page, text: []const u8) !*Node {
// might seem unlikely that we get an intern hit, but we'll get some nodes
// with just '\n'
const owned_text = try self.dupeString(text);
const cd = try self._factory.node(CData{ const cd = try self._factory.node(CData{
._proto = undefined, ._proto = undefined,
._type = .{ .text = .{ ._type = .{ .text = .{
._proto = undefined, ._proto = undefined,
} }, } },
._data = owned_text, ._data = try self.dupeSSO(text),
}); });
cd._type.text._proto = cd; cd._type.text._proto = cd;
return cd.asNode(); return cd.asNode();
} }
pub fn createComment(self: *Page, text: []const u8) !*Node { pub fn createComment(self: *Page, text: []const u8) !*Node {
const owned_text = try self.dupeString(text);
const cd = try self._factory.node(CData{ const cd = try self._factory.node(CData{
._proto = undefined, ._proto = undefined,
._type = .{ .comment = .{ ._type = .{ .comment = .{
._proto = undefined, ._proto = undefined,
} }, } },
._data = owned_text, ._data = try self.dupeSSO(text),
}); });
cd._type.comment._proto = cd; cd._type.comment._proto = cd;
return cd.asNode(); return cd.asNode();
@@ -2467,8 +2215,6 @@ pub fn createCDATASection(self: *Page, data: []const u8) !*Node {
return error.InvalidCharacterError; return error.InvalidCharacterError;
} }
const owned_data = try self.dupeString(data);
// First allocate the Text node separately // First allocate the Text node separately
const text_node = try self._factory.create(CData.Text{ const text_node = try self._factory.create(CData.Text{
._proto = undefined, ._proto = undefined,
@@ -2480,7 +2226,7 @@ pub fn createCDATASection(self: *Page, data: []const u8) !*Node {
._type = .{ .cdata_section = .{ ._type = .{ .cdata_section = .{
._proto = text_node, ._proto = text_node,
} }, } },
._data = owned_data, ._data = try self.dupeSSO(data),
}); });
// Set up the back pointer from Text to CData // Set up the back pointer from Text to CData
@@ -2502,7 +2248,6 @@ pub fn createProcessingInstruction(self: *Page, target: []const u8, data: []cons
try validateXmlName(target); try validateXmlName(target);
const owned_target = try self.dupeString(target); const owned_target = try self.dupeString(target);
const owned_data = try self.dupeString(data);
const pi = try self._factory.create(CData.ProcessingInstruction{ const pi = try self._factory.create(CData.ProcessingInstruction{
._proto = undefined, ._proto = undefined,
@@ -2512,7 +2257,7 @@ pub fn createProcessingInstruction(self: *Page, target: []const u8, data: []cons
const cd = try self._factory.node(CData{ const cd = try self._factory.node(CData{
._proto = undefined, ._proto = undefined,
._type = .{ .processing_instruction = pi }, ._type = .{ .processing_instruction = pi },
._data = owned_data, ._data = try self.dupeSSO(data),
}); });
// Set up the back pointer from ProcessingInstruction to CData // Set up the back pointer from ProcessingInstruction to CData
@@ -2585,6 +2330,10 @@ pub fn dupeString(self: *Page, value: []const u8) ![]const u8 {
return self.arena.dupe(u8, value); return self.arena.dupe(u8, value);
} }
pub fn dupeSSO(self: *Page, value: []const u8) !String {
return String.init(self.arena, value, .{ .dupe = true });
}
const RemoveNodeOpts = struct { const RemoveNodeOpts = struct {
will_be_reconnected: bool, will_be_reconnected: bool,
}; };
@@ -2851,7 +2600,7 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
pub fn attributeChange(self: *Page, element: *Element, name: String, value: String, old_value: ?String) void { pub fn attributeChange(self: *Page, element: *Element, name: String, value: String, old_value: ?String) void {
_ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| { _ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| {
log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type }); log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type, .url = self.url });
}; };
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, self); Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, self);
@@ -2860,7 +2609,7 @@ pub fn attributeChange(self: *Page, element: *Element, name: String, value: Stri
while (it) |node| : (it = node.next) { while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node); const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.notifyAttributeChange(element, name, old_value, self) catch |err| { observer.notifyAttributeChange(element, name, old_value, self) catch |err| {
log.err(.page, "attributeChange.notifyObserver", .{ .err = err, .type = self._type }); log.err(.page, "attributeChange.notifyObserver", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
@@ -2877,7 +2626,7 @@ pub fn attributeChange(self: *Page, element: *Element, name: String, value: Stri
pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: String) void { pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: String) void {
_ = Element.Build.call(element, "attributeRemove", .{ element, name, self }) catch |err| { _ = Element.Build.call(element, "attributeRemove", .{ element, name, self }) catch |err| {
log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type }); log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type, .url = self.url });
}; };
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, self); Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, self);
@@ -2886,7 +2635,7 @@ pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value:
while (it) |node| : (it = node.next) { while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node); const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.notifyAttributeChange(element, name, old_value, self) catch |err| { observer.notifyAttributeChange(element, name, old_value, self) catch |err| {
log.err(.page, "attributeRemove.notifyObserver", .{ .err = err, .type = self._type }); log.err(.page, "attributeRemove.notifyObserver", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
@@ -2903,11 +2652,11 @@ pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value:
fn signalSlotChange(self: *Page, slot: *Element.Html.Slot) void { fn signalSlotChange(self: *Page, slot: *Element.Html.Slot) void {
self._slots_pending_slotchange.put(self.arena, slot, {}) catch |err| { self._slots_pending_slotchange.put(self.arena, slot, {}) catch |err| {
log.err(.page, "signalSlotChange.put", .{ .err = err, .type = self._type }); log.err(.page, "signalSlotChange.put", .{ .err = err, .type = self._type, .url = self.url });
return; return;
}; };
self.scheduleSlotchangeDelivery() catch |err| { self.scheduleSlotchangeDelivery() catch |err| {
log.err(.page, "signalSlotChange.schedule", .{ .err = err, .type = self._type }); log.err(.page, "signalSlotChange.schedule", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
@@ -2947,7 +2696,7 @@ fn updateElementAssignedSlot(self: *Page, element: *Element) void {
// Recursively search through the shadow root for a matching slot // Recursively search through the shadow root for a matching slot
if (findMatchingSlot(shadow_root.asNode(), slot_name)) |slot| { if (findMatchingSlot(shadow_root.asNode(), slot_name)) |slot| {
self._element_assigned_slots.put(self.arena, element, slot) catch |err| { self._element_assigned_slots.put(self.arena, element, slot) catch |err| {
log.err(.page, "updateElementAssignedSlot.put", .{ .err = err, .type = self._type }); log.err(.page, "updateElementAssignedSlot.put", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
} }
@@ -2988,13 +2737,13 @@ pub fn setCustomizedBuiltInDefinition(self: *Page, element: *Element, definition
pub fn characterDataChange( pub fn characterDataChange(
self: *Page, self: *Page,
target: *Node, target: *Node,
old_value: []const u8, old_value: String,
) void { ) void {
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first; var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) { while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node); const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.notifyCharacterDataChange(target, old_value, self) catch |err| { observer.notifyCharacterDataChange(target, old_value, self) catch |err| {
log.err(.page, "cdataChange.notifyObserver", .{ .err = err, .type = self._type }); log.err(.page, "cdataChange.notifyObserver", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
} }
@@ -3021,7 +2770,7 @@ pub fn childListChange(
while (it) |node| : (it = node.next) { while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node); const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.notifyChildListChange(target, added_nodes, removed_nodes, previous_sibling, next_sibling, self) catch |err| { observer.notifyChildListChange(target, added_nodes, removed_nodes, previous_sibling, next_sibling, self) catch |err| {
log.err(.page, "childListChange.notifyObserver", .{ .err = err, .type = self._type }); log.err(.page, "childListChange.notifyObserver", .{ .err = err, .type = self._type, .url = self.url });
}; };
} }
} }
@@ -3072,7 +2821,7 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void {
} }
self.scriptAddedCallback(from_parser, script) catch |err| { self.scriptAddedCallback(from_parser, script) catch |err| {
log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "script", .type = self._type }); log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "script", .type = self._type, .url = self.url });
return err; return err;
}; };
} else if (node.is(Element.Html.IFrame)) |iframe| { } else if (node.is(Element.Html.IFrame)) |iframe| {
@@ -3082,9 +2831,19 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void {
} }
self.iframeAddedCallback(iframe) catch |err| { self.iframeAddedCallback(iframe) catch |err| {
log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "iframe", .type = self._type }); log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "iframe", .type = self._type, .url = self.url });
return err; return err;
}; };
} else if (node.is(Element.Html.Link)) |link| {
link.linkAddedCallback(self) catch |err| {
log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "link", .type = self._type });
return error.LinkLoadError;
};
} else if (node.is(Element.Html.Style)) |style| {
style.styleAddedCallback(self) catch |err| {
log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "style", .type = self._type });
return error.StyleLoadError;
};
} }
} }
@@ -3242,8 +3001,6 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void {
.clientX = x, .clientX = x,
.clientY = y, .clientY = y,
}, self)).asEvent(); }, self)).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
try self._event_manager.dispatch(target.asEventTarget(), event); try self._event_manager.dispatch(target.asEventTarget(), event);
} }
@@ -3267,12 +3024,12 @@ pub fn handleClick(self: *Page, target: *Node) !void {
// Check target attribute - don't navigate if opening in new window/tab // Check target attribute - don't navigate if opening in new window/tab
const target_val = anchor.getTarget(); const target_val = anchor.getTarget();
if (target_val.len > 0 and !std.mem.eql(u8, target_val, "_self")) { if (target_val.len > 0 and !std.mem.eql(u8, target_val, "_self")) {
log.warn(.not_implemented, "a.target", .{ .type = self._type }); log.warn(.not_implemented, "a.target", .{ .type = self._type, .url = self.url });
return; return;
} }
if (try element.hasAttribute(comptime .wrap("download"), self)) { if (try element.hasAttribute(comptime .wrap("download"), self)) {
log.warn(.browser, "a.download", .{ .type = self._type }); log.warn(.browser, "a.download", .{ .type = self._type, .url = self.url });
return; return;
} }
@@ -3301,8 +3058,6 @@ pub fn handleClick(self: *Page, target: *Node) !void {
pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void { pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void {
const event = keyboard_event.asEvent(); const event = keyboard_event.asEvent();
defer if (!event._v8_handoff) event.deinit(false);
const element = self.window._document._active_element orelse return; const element = self.window._document._active_element orelse return;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.page, "page keydown", .{ log.debug(.page, "page keydown", .{
@@ -3316,7 +3071,7 @@ pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void {
} }
pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void { pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void {
const keyboard_event = event.as(KeyboardEvent); const keyboard_event = event.is(KeyboardEvent) orelse return;
const key = keyboard_event.getKey(); const key = keyboard_event.getKey();
if (key == .Dead) { if (key == .Dead) {
@@ -3372,10 +3127,8 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
const form_element = form.asElement(); const form_element = form.asElement();
if (submit_opts.fire_event) { if (submit_opts.fire_event) {
const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self);
defer if (!submit_event._v8_handoff) submit_event.deinit(false);
const onsubmit_handler = try form.asHtmlElement().getOnSubmit(self); const onsubmit_handler = try form.asHtmlElement().getOnSubmit(self);
const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self);
var ls: JS.Local.Scope = undefined; var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls); self.js.localScope(&ls);

View File

@@ -20,13 +20,14 @@ const std = @import("std");
const lp = @import("lightpanda"); const lp = @import("lightpanda");
const builtin = @import("builtin"); const builtin = @import("builtin");
const js = @import("js/js.zig");
const log = @import("../log.zig"); const log = @import("../log.zig");
const Http = @import("../http/Http.zig");
const String = @import("../string.zig").String;
const js = @import("js/js.zig");
const URL = @import("URL.zig"); const URL = @import("URL.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const Browser = @import("Browser.zig"); const Browser = @import("Browser.zig");
const Http = @import("../http/Http.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
@@ -581,12 +582,6 @@ fn evaluate(self: *ScriptManager) void {
} }
} }
pub fn isDone(self: *const ScriptManager) bool {
return self.static_scripts_done and // page is done processing initial html
self.defer_scripts.first == null and // no deferred scripts
self.async_scripts.first == null; // no async scripts
}
fn parseImportmap(self: *ScriptManager, script: *const Script) !void { fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
const content = script.source.content(); const content = script.source.content();
@@ -627,8 +622,19 @@ pub const Script = struct {
node: std.DoublyLinkedList.Node, node: std.DoublyLinkedList.Node,
script_element: ?*Element.Html.Script, script_element: ?*Element.Html.Script,
manager: *ScriptManager, manager: *ScriptManager,
// for debugging a rare production issue
header_callback_called: bool = false, header_callback_called: bool = false,
// for debugging a rare production issue
debug_transfer_id: u32 = 0,
debug_transfer_tries: u8 = 0,
debug_transfer_aborted: bool = false,
debug_transfer_bytes_received: usize = 0,
debug_transfer_notified_fail: bool = false,
debug_transfer_redirecting: bool = false,
debug_transfer_intercept_state: u8 = 0,
const Kind = enum { const Kind = enum {
module, module,
javascript, javascript,
@@ -696,8 +702,31 @@ pub const Script = struct {
// temp debug, trying to figure out why the next assert sometimes // temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really // fails. Is the buffer just corrupt or is headerCallback really
// being called twice? // being called twice?
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{}); 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,
.a6 = self.debug_transfer_redirecting,
.a7 = self.debug_transfer_intercept_state,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.aborted,
.b4 = transfer.bytes_received,
.b5 = transfer._notified_fail,
.b6 = transfer._redirecting,
.b7 = @intFromEnum(transfer._intercept_state),
});
self.header_callback_called = true; 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_redirecting = transfer._redirecting;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
} }
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity }); lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
@@ -830,13 +859,15 @@ pub const Script = struct {
.kind = self.kind, .kind = self.kind,
.cacheable = cacheable, .cacheable = cacheable,
}); });
self.executeCallback("error", local.toLocal(script_element._on_error), page); self.executeCallback(comptime .wrap("error"), page);
return; return;
}; };
self.executeCallback("load", local.toLocal(script_element._on_load), page); self.executeCallback(comptime .wrap("load"), page);
return; return;
} }
defer page._event_manager.clearIgnoreList();
var try_catch: js.TryCatch = undefined; var try_catch: js.TryCatch = undefined;
try_catch.init(local); try_catch.init(local);
defer try_catch.deinit(); defer try_catch.deinit();
@@ -855,19 +886,18 @@ pub const Script = struct {
}; };
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.browser, "executed script", .{ .src = url, .success = success, .on_load = script_element._on_load != null }); log.debug(.browser, "executed script", .{ .src = url, .success = success });
} }
defer { defer {
// We should run microtasks even if script execution fails. local.runMacrotasks(); // also runs microtasks
local.runMicrotasks();
_ = page.js.scheduler.run() catch |err| { _ = page.js.scheduler.run() catch |err| {
log.err(.page, "scheduler", .{ .err = err }); log.err(.page, "scheduler", .{ .err = err });
}; };
} }
if (success) { if (success) {
self.executeCallback("load", local.toLocal(script_element._on_load), page); self.executeCallback(comptime .wrap("load"), page);
return; return;
} }
@@ -878,14 +908,12 @@ pub const Script = struct {
.cacheable = cacheable, .cacheable = cacheable,
}); });
self.executeCallback("error", local.toLocal(script_element._on_error), page); self.executeCallback(comptime .wrap("error"), page);
} }
fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function, page: *Page) void { fn executeCallback(self: *const Script, typ: String, page: *Page) void {
const cb = cb_ orelse return;
const Event = @import("webapi/Event.zig"); const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(comptime .wrap(typ), .{}, page) catch |err| { const event = Event.initTrusted(typ, .{}, page) catch |err| {
log.warn(.js, "script internal callback", .{ log.warn(.js, "script internal callback", .{
.url = self.url, .url = self.url,
.type = typ, .type = typ,
@@ -893,14 +921,11 @@ pub const Script = struct {
}); });
return; return;
}; };
defer if (!event._v8_handoff) event.deinit(false); page._event_manager.dispatchOpts(self.script_element.?.asNode().asEventTarget(), event, .{ .apply_ignore = true }) catch |err| {
var caught: js.TryCatch.Caught = undefined;
cb.tryCall(void, .{event}, &caught) catch {
log.warn(.js, "script callback", .{ log.warn(.js, "script callback", .{
.url = self.url, .url = self.url,
.type = typ, .type = typ,
.caught = caught, .err = err,
}); });
}; };
} }
@@ -1020,23 +1045,35 @@ fn parseDataURI(allocator: Allocator, src: []const u8) !?[]const u8 {
const uri = src[5..]; const uri = src[5..];
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null; const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
const data = uri[data_starts + 1 ..];
var data = uri[data_starts + 1 ..]; const unescaped = try URL.unescape(allocator, data);
// Extract the encoding.
const metadata = uri[0..data_starts]; const metadata = uri[0..data_starts];
if (std.mem.endsWith(u8, metadata, ";base64")) { if (std.mem.endsWith(u8, metadata, ";base64") == false) {
const decoder = std.base64.standard.Decoder; return unescaped;
const decoded_size = try decoder.calcSizeForSlice(data);
const buffer = try allocator.alloc(u8, decoded_size);
errdefer allocator.free(buffer);
try decoder.decode(buffer, data);
data = buffer;
} }
return data; // Forgiving base64 decode per WHATWG spec:
// https://infra.spec.whatwg.org/#forgiving-base64-decode
// Step 1: Remove all ASCII whitespace
var stripped = try std.ArrayList(u8).initCapacity(allocator, unescaped.len);
for (unescaped) |c| {
if (!std.ascii.isWhitespace(c)) {
stripped.appendAssumeCapacity(c);
}
}
const trimmed = std.mem.trimRight(u8, stripped.items, "=");
// Length % 4 == 1 is invalid
if (trimmed.len % 4 == 1) {
return error.InvalidCharacterError;
}
const decoded_size = std.base64.standard_no_pad.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError;
const buffer = try allocator.alloc(u8, decoded_size);
std.base64.standard_no_pad.Decoder.decode(buffer, trimmed) catch return error.InvalidCharacterError;
return buffer;
} }
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");

View File

@@ -166,6 +166,7 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult {
error.JsError => {}, // already logged (with hopefully more context) error.JsError => {}, // already logged (with hopefully more context)
else => log.err(.browser, "session wait", .{ else => log.err(.browser, "session wait", .{
.err = err, .err = err,
.url = page.url,
}), }),
} }
return .done; return .done;
@@ -240,6 +241,9 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// it AFTER. // it AFTER.
const ms_to_next_task = try browser.runMacrotasks(); const ms_to_next_task = try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
const http_active = http_client.active; const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted; const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
@@ -257,7 +261,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
std.debug.assert(http_client.intercepted == 0); std.debug.assert(http_client.intercepted == 0);
} }
const ms = ms_to_next_task orelse blk: { const ms: u64 = ms_to_next_task orelse blk: {
if (wait_ms - ms_remaining < 100) { if (wait_ms - ms_remaining < 100) {
if (comptime builtin.is_test) { if (comptime builtin.is_test) {
return .done; return .done;
@@ -267,6 +271,14 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// background jobs. // background jobs.
break :blk 50; break :blk 50;
} }
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
break :blk 20;
}
// No http transfers, no cdp extra socket, no // No http transfers, no cdp extra socket, no
// scheduled tasks, we're done. // scheduled tasks, we're done.
return .done; return .done;
@@ -292,8 +304,14 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// an cdp_socket registered with the http client). // an cdp_socket registered with the http client).
// We should continue to run lowPriority tasks, so we // We should continue to run lowPriority tasks, so we
// minimize how long we'll poll for network I/O. // minimize how long we'll poll for network I/O.
const ms_to_wait = @min(200, @min(ms_remaining, ms_to_next_task orelse 200)); var ms_to_wait = @min(200, ms_to_next_task orelse 200);
if (try http_client.tick(ms_to_wait) == .cdp_socket) { if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back
// to the top of the loop and run macrotasks.
ms_to_wait = 10;
}
if (try http_client.tick(@min(ms_remaining, ms_to_wait)) == .cdp_socket) {
// data on a socket we aren't handling, return to caller // data on a socket we aren't handling, return to caller
return .cdp_socket; return .cdp_socket;
} }

View File

@@ -20,44 +20,61 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ResolveOpts = struct { const ResolveOpts = struct {
encode: bool = false,
always_dupe: bool = false, always_dupe: bool = false,
}; };
// path is anytype, so that it can be used with both []const u8 and [:0]const u8 // 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 { pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 {
const PT = @TypeOf(path); const PT = @TypeOf(path);
if (base.len == 0 or isCompleteHTTPUrl(path)) { if (base.len == 0 or isCompleteHTTPUrl(path)) {
if (comptime opts.always_dupe or !isNullTerminated(PT)) { if (comptime opts.always_dupe or !isNullTerminated(PT)) {
return allocator.dupeZ(u8, path); const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts);
}
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
} }
return path; return path;
} }
if (path.len == 0) { if (path.len == 0) {
if (comptime opts.always_dupe) { if (comptime opts.always_dupe) {
return allocator.dupeZ(u8, base); const duped = try allocator.dupeZ(u8, base);
return processResolved(allocator, duped, opts);
}
if (comptime opts.encode) {
return processResolved(allocator, base, opts);
} }
return base; return base;
} }
if (path[0] == '?') { if (path[0] == '?') {
const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len; const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len;
return std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path }); const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path });
return processResolved(allocator, result, opts);
} }
if (path[0] == '#') { if (path[0] == '#') {
const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len; const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len;
return std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path }); const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path });
return processResolved(allocator, result, opts);
} }
if (std.mem.startsWith(u8, path, "//")) { if (std.mem.startsWith(u8, path, "//")) {
// network-path reference // network-path reference
const index = std.mem.indexOfScalar(u8, base, ':') orelse { const index = std.mem.indexOfScalar(u8, base, ':') orelse {
if (comptime isNullTerminated(PT)) { if (comptime isNullTerminated(PT)) {
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
}
return path; return path;
} }
return allocator.dupeZ(u8, path); const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts);
}; };
const protocol = base[0 .. index + 1]; const protocol = base[0 .. index + 1];
return std.mem.joinZ(allocator, "", &.{ protocol, path }); const result = try std.mem.joinZ(allocator, "", &.{ protocol, path });
return processResolved(allocator, result, opts);
} }
const scheme_end = std.mem.indexOf(u8, base, "://"); const scheme_end = std.mem.indexOf(u8, base, "://");
@@ -65,7 +82,8 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len; const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len;
if (path[0] == '/') { if (path[0] == '/') {
return std.mem.joinZ(allocator, "", &.{ base[0..path_start], path }); const result = try std.mem.joinZ(allocator, "", &.{ base[0..path_start], path });
return processResolved(allocator, result, opts);
} }
var normalized_base: []const u8 = base[0..path_start]; var normalized_base: []const u8 = base[0..path_start];
@@ -127,7 +145,119 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
// we always have an extra space // we always have an extra space
out[out_i] = 0; out[out_i] = 0;
return out[0..out_i :0]; return processResolved(allocator, out[0..out_i :0], opts);
}
fn processResolved(allocator: Allocator, url: [:0]const u8, comptime opts: ResolveOpts) ![:0]const u8 {
if (!comptime opts.encode) {
return url;
}
return ensureEncoded(allocator, url);
}
pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
const scheme_end = std.mem.indexOf(u8, url, "://");
const authority_start = if (scheme_end) |end| end + 3 else 0;
const path_start = std.mem.indexOfScalarPos(u8, url, authority_start, '/') orelse return url;
const query_start = std.mem.indexOfScalarPos(u8, url, path_start, '?');
const fragment_start = std.mem.indexOfScalarPos(u8, url, query_start orelse path_start, '#');
const path_end = query_start orelse fragment_start orelse url.len;
const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end;
const path_to_encode = url[path_start..path_end];
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, true);
const encoded_query = if (query_start) |qs| blk: {
const query_to_encode = url[qs + 1 .. query_end];
const encoded = try percentEncodeSegment(allocator, query_to_encode, false);
break :blk encoded;
} else null;
const encoded_fragment = if (fragment_start) |fs| blk: {
const fragment_to_encode = url[fs + 1 ..];
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, false);
break :blk encoded;
} else null;
if (encoded_path.ptr == path_to_encode.ptr and
(encoded_query == null or encoded_query.?.ptr == url[query_start.? + 1 .. query_end].ptr) and
(encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr))
{
// nothing has changed
return url;
}
var buf = try std.ArrayList(u8).initCapacity(allocator, url.len + 20);
try buf.appendSlice(allocator, url[0..path_start]);
try buf.appendSlice(allocator, encoded_path);
if (encoded_query) |eq| {
try buf.append(allocator, '?');
try buf.appendSlice(allocator, eq);
}
if (encoded_fragment) |ef| {
try buf.append(allocator, '#');
try buf.appendSlice(allocator, ef);
}
try buf.append(allocator, 0);
return buf.items[0 .. buf.items.len - 1 :0];
}
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_path: bool) ![]const u8 {
// Check if encoding is needed
var needs_encoding = false;
for (segment) |c| {
if (shouldPercentEncode(c, is_path)) {
needs_encoding = true;
break;
}
}
if (!needs_encoding) {
return segment;
}
var buf = try std.ArrayList(u8).initCapacity(allocator, segment.len + 10);
var i: usize = 0;
while (i < segment.len) : (i += 1) {
const c = segment[i];
// Check if this is an already-encoded sequence (%XX)
if (c == '%' and i + 2 < segment.len) {
const end = i + 2;
const h1 = segment[i + 1];
const h2 = segment[end];
if (std.ascii.isHex(h1) and std.ascii.isHex(h2)) {
try buf.appendSlice(allocator, segment[i .. end + 1]);
i = end;
continue;
}
}
if (shouldPercentEncode(c, is_path)) {
try buf.writer(allocator).print("%{X:0>2}", .{c});
} else {
try buf.append(allocator, c);
}
}
return buf.items;
}
fn shouldPercentEncode(c: u8, comptime is_path: bool) bool {
return switch (c) {
// Unreserved characters (RFC 3986)
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false,
// sub-delims allowed in both path and query
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => false,
// Separators allowed in both path and query
'/', ':', '@' => false,
// Query-specific: '?' is allowed in queries but not in paths
'?' => comptime is_path,
// Everything else needs encoding (including space)
else => true,
};
} }
fn isNullTerminated(comptime value: type) bool { fn isNullTerminated(comptime value: type) bool {
@@ -512,6 +642,33 @@ pub fn getRobotsUrl(arena: Allocator, url: [:0]const u8) ![:0]const u8 {
); );
} }
pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
if (std.mem.indexOfScalar(u8, input, '%') == null) {
return input;
}
var result = try std.ArrayList(u8).initCapacity(arena, input.len);
var i: usize = 0;
while (i < input.len) {
if (input[i] == '%' and i + 2 < input.len) {
const hex = input[i + 1 .. i + 3];
const byte = std.fmt.parseInt(u8, hex, 16) catch {
result.appendAssumeCapacity(input[i]);
i += 1;
continue;
};
result.appendAssumeCapacity(byte);
i += 3;
} else {
result.appendAssumeCapacity(input[i]);
i += 1;
}
}
return result.items;
}
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "URL: isCompleteHTTPUrl" { test "URL: isCompleteHTTPUrl" {
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about")); try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
@@ -691,6 +848,293 @@ test "URL: resolve" {
} }
} }
test "URL: ensureEncoded" {
defer testing.reset();
const Case = struct {
url: [:0]const u8,
expected: [:0]const u8,
};
const cases = [_]Case{
.{
.url = "https://example.com/over 9000!",
.expected = "https://example.com/over%209000!",
},
.{
.url = "http://example.com/hello world.html",
.expected = "http://example.com/hello%20world.html",
},
.{
.url = "https://example.com/file[1].html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.url = "https://example.com/file{name}.html",
.expected = "https://example.com/file%7Bname%7D.html",
},
.{
.url = "https://example.com/page?query=hello world",
.expected = "https://example.com/page?query=hello%20world",
},
.{
.url = "https://example.com/page?a=1&b=value with spaces",
.expected = "https://example.com/page?a=1&b=value%20with%20spaces",
},
.{
.url = "https://example.com/page#section one",
.expected = "https://example.com/page#section%20one",
},
.{
.url = "https://example.com/my path?query=my value#my anchor",
.expected = "https://example.com/my%20path?query=my%20value#my%20anchor",
},
.{
.url = "https://example.com/already%20encoded",
.expected = "https://example.com/already%20encoded",
},
.{
.url = "https://example.com/file%5B1%5D.html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.url = "https://example.com/caf%C3%A9",
.expected = "https://example.com/caf%C3%A9",
},
.{
.url = "https://example.com/page?query=already%20encoded",
.expected = "https://example.com/page?query=already%20encoded",
},
.{
.url = "https://example.com/page?a=1&b=value%20here",
.expected = "https://example.com/page?a=1&b=value%20here",
},
.{
.url = "https://example.com/page#section%20one",
.expected = "https://example.com/page#section%20one",
},
.{
.url = "https://example.com/part%20encoded and not",
.expected = "https://example.com/part%20encoded%20and%20not",
},
.{
.url = "https://example.com/page?a=encoded%20value&b=not encoded",
.expected = "https://example.com/page?a=encoded%20value&b=not%20encoded",
},
.{
.url = "https://example.com/my%20path?query=not encoded#encoded%20anchor",
.expected = "https://example.com/my%20path?query=not%20encoded#encoded%20anchor",
},
.{
.url = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
.expected = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
},
.{
.url = "https://example.com/path-with_under~tilde",
.expected = "https://example.com/path-with_under~tilde",
},
.{
.url = "https://example.com/sub-delims!$&'()*+,;=",
.expected = "https://example.com/sub-delims!$&'()*+,;=",
},
.{
.url = "https://example.com",
.expected = "https://example.com",
},
.{
.url = "https://example.com?query=value",
.expected = "https://example.com?query=value",
},
.{
.url = "https://example.com/clean/path",
.expected = "https://example.com/clean/path",
},
.{
.url = "https://example.com/path?clean=query#clean-fragment",
.expected = "https://example.com/path?clean=query#clean-fragment",
},
.{
.url = "https://example.com/100% complete",
.expected = "https://example.com/100%25%20complete",
},
.{
.url = "https://example.com/path?value=100% done",
.expected = "https://example.com/path?value=100%25%20done",
},
};
for (cases) |case| {
const result = try ensureEncoded(testing.arena_allocator, case.url);
try testing.expectString(case.expected, result);
}
}
test "URL: resolve with encoding" {
defer testing.reset();
const Case = struct {
base: [:0]const u8,
path: [:0]const u8,
expected: [:0]const u8,
};
const cases = [_]Case{
// Spaces should be encoded as %20, but ! is allowed
.{
.base = "https://example.com/dir/",
.path = "over 9000!",
.expected = "https://example.com/dir/over%209000!",
},
.{
.base = "https://example.com/",
.path = "hello world.html",
.expected = "https://example.com/hello%20world.html",
},
// Multiple spaces
.{
.base = "https://example.com/",
.path = "path with multiple spaces",
.expected = "https://example.com/path%20with%20%20multiple%20%20%20spaces",
},
// Special characters that need encoding
.{
.base = "https://example.com/",
.path = "file[1].html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.base = "https://example.com/",
.path = "file{name}.html",
.expected = "https://example.com/file%7Bname%7D.html",
},
.{
.base = "https://example.com/",
.path = "file<test>.html",
.expected = "https://example.com/file%3Ctest%3E.html",
},
.{
.base = "https://example.com/",
.path = "file\"quote\".html",
.expected = "https://example.com/file%22quote%22.html",
},
.{
.base = "https://example.com/",
.path = "file|pipe.html",
.expected = "https://example.com/file%7Cpipe.html",
},
.{
.base = "https://example.com/",
.path = "file\\backslash.html",
.expected = "https://example.com/file%5Cbackslash.html",
},
.{
.base = "https://example.com/",
.path = "file^caret.html",
.expected = "https://example.com/file%5Ecaret.html",
},
.{
.base = "https://example.com/",
.path = "file`backtick`.html",
.expected = "https://example.com/file%60backtick%60.html",
},
// Characters that should NOT be encoded
.{
.base = "https://example.com/",
.path = "path-with_under~tilde.html",
.expected = "https://example.com/path-with_under~tilde.html",
},
.{
.base = "https://example.com/",
.path = "path/with/slashes",
.expected = "https://example.com/path/with/slashes",
},
.{
.base = "https://example.com/",
.path = "sub-delims!$&'()*+,;=.html",
.expected = "https://example.com/sub-delims!$&'()*+,;=.html",
},
// Already encoded characters should not be double-encoded
.{
.base = "https://example.com/",
.path = "already%20encoded",
.expected = "https://example.com/already%20encoded",
},
.{
.base = "https://example.com/",
.path = "file%5B1%5D.html",
.expected = "https://example.com/file%5B1%5D.html",
},
// Mix of encoded and unencoded
.{
.base = "https://example.com/",
.path = "part%20encoded and not",
.expected = "https://example.com/part%20encoded%20and%20not",
},
// Query strings and fragments ARE encoded
.{
.base = "https://example.com/",
.path = "file name.html?query=value with spaces",
.expected = "https://example.com/file%20name.html?query=value%20with%20spaces",
},
.{
.base = "https://example.com/",
.path = "file name.html#anchor with spaces",
.expected = "https://example.com/file%20name.html#anchor%20with%20spaces",
},
.{
.base = "https://example.com/",
.path = "file.html?hello=world !",
.expected = "https://example.com/file.html?hello=world%20!",
},
// Query structural characters should NOT be encoded
.{
.base = "https://example.com/",
.path = "file.html?a=1&b=2",
.expected = "https://example.com/file.html?a=1&b=2",
},
// Relative paths with encoding
.{
.base = "https://example.com/dir/page.html",
.path = "../other dir/file.html",
.expected = "https://example.com/other%20dir/file.html",
},
.{
.base = "https://example.com/dir/",
.path = "./sub dir/file.html",
.expected = "https://example.com/dir/sub%20dir/file.html",
},
// Absolute paths with encoding
.{
.base = "https://example.com/some/path",
.path = "/absolute path/file.html",
.expected = "https://example.com/absolute%20path/file.html",
},
// Unicode/high bytes (though ideally these should be UTF-8 encoded first)
.{
.base = "https://example.com/",
.path = "café",
.expected = "https://example.com/caf%C3%A9",
},
// Empty path
.{
.base = "https://example.com/",
.path = "",
.expected = "https://example.com/",
},
// Complete URL as path (should not be encoded)
.{
.base = "https://example.com/",
.path = "https://other.com/path with spaces",
.expected = "https://other.com/path%20with%20spaces",
},
};
for (cases) |case| {
const result = try resolve(testing.arena_allocator, case.base, case.path, .{ .encode = true });
try testing.expectString(case.expected, result);
}
}
test "URL: eqlDocument" { test "URL: eqlDocument" {
defer testing.reset(); defer testing.reset();
{ {
@@ -816,3 +1260,68 @@ test "URL: getRobotsUrl" {
try testing.expectString("https://example.com/robots.txt", url); try testing.expectString("https://example.com/robots.txt", url);
} }
} }
test "URL: unescape" {
defer testing.reset();
const arena = testing.arena_allocator;
{
const result = try unescape(arena, "hello world");
try testing.expectEqual("hello world", result);
}
{
const result = try unescape(arena, "hello%20world");
try testing.expectEqual("hello world", result);
}
{
const result = try unescape(arena, "%48%65%6c%6c%6f");
try testing.expectEqual("Hello", result);
}
{
const result = try unescape(arena, "%48%65%6C%6C%6F");
try testing.expectEqual("Hello", result);
}
{
const result = try unescape(arena, "a%3Db");
try testing.expectEqual("a=b", result);
}
{
const result = try unescape(arena, "a%3DB");
try testing.expectEqual("a=B", result);
}
{
const result = try unescape(arena, "ZDIgPSAndHdvJzs%3D");
try testing.expectEqual("ZDIgPSAndHdvJzs=", result);
}
{
const result = try unescape(arena, "%5a%44%4d%67%50%53%41%6e%64%47%68%79%5a%57%55%6e%4f%77%3D%3D");
try testing.expectEqual("ZDMgPSAndGhyZWUnOw==", result);
}
{
const result = try unescape(arena, "hello%2world");
try testing.expectEqual("hello%2world", result);
}
{
const result = try unescape(arena, "hello%ZZworld");
try testing.expectEqual("hello%ZZworld", result);
}
{
const result = try unescape(arena, "hello%");
try testing.expectEqual("hello%", result);
}
{
const result = try unescape(arena, "hello%2");
try testing.expectEqual("hello%2", result);
}
}

View File

@@ -583,7 +583,7 @@ fn consumeNumeric(self: *Tokenizer) Token {
}; };
self.advance(2); self.advance(2);
} else if (self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(2))) { } else if (self.hasAtLeast(2) and std.ascii.isDigit(self.byteAt(2))) {
self.advance(1); self.advance(1);
} else { } else {
break :blk; break :blk;

View File

@@ -20,16 +20,15 @@ const std = @import("std");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig"); const Node = @import("webapi/Node.zig");
const Slot = @import("webapi/element/html/Slot.zig"); const Slot = @import("webapi/element/html/Slot.zig");
const IFrame = @import("webapi/element/html/IFrame.zig");
pub const RootOpts = struct { const IS_DEBUG = @import("builtin").mode == .Debug;
with_base: bool = false,
strip: Opts.Strip = .{},
shadow: Opts.Shadow = .rendered,
};
pub const Opts = struct { pub const Opts = struct {
strip: Strip = .{}, with_base: bool = false,
shadow: Shadow = .rendered, with_frames: bool = false,
strip: Opts.Strip = .{},
shadow: Opts.Shadow = .rendered,
pub const Strip = struct { pub const Strip = struct {
js: bool = false, js: bool = false,
@@ -49,7 +48,7 @@ pub const Opts = struct {
}; };
}; };
pub fn root(doc: *Node.Document, opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { pub fn root(doc: *Node.Document, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
if (doc.is(Node.Document.HTMLDocument)) |html_doc| { if (doc.is(Node.Document.HTMLDocument)) |html_doc| {
blk: { blk: {
// Ideally we just render the doctype which is part of the document // Ideally we just render the doctype which is part of the document
@@ -71,7 +70,7 @@ pub fn root(doc: *Node.Document, opts: RootOpts, writer: *std.Io.Writer, page: *
} }
} }
return deep(doc.asNode(), .{ .strip = opts.strip, .shadow = opts.shadow }, writer, page); return deep(doc.asNode(), opts, writer, page);
} }
pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
@@ -83,19 +82,19 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
.cdata => |cd| { .cdata => |cd| {
if (node.is(Node.CData.Comment)) |_| { if (node.is(Node.CData.Comment)) |_| {
try writer.writeAll("<!--"); try writer.writeAll("<!--");
try writer.writeAll(cd.getData()); try writer.writeAll(cd.getData().str());
try writer.writeAll("-->"); try writer.writeAll("-->");
} else if (node.is(Node.CData.ProcessingInstruction)) |pi| { } else if (node.is(Node.CData.ProcessingInstruction)) |pi| {
try writer.writeAll("<?"); try writer.writeAll("<?");
try writer.writeAll(pi._target); try writer.writeAll(pi._target);
try writer.writeAll(" "); try writer.writeAll(" ");
try writer.writeAll(cd.getData()); try writer.writeAll(cd.getData().str());
try writer.writeAll("?>"); try writer.writeAll("?>");
} else { } else {
if (shouldEscapeText(node._parent)) { if (shouldEscapeText(node._parent)) {
try writeEscapedText(cd.getData(), writer); try writeEscapedText(cd.getData().str(), writer);
} else { } else {
try writer.writeAll(cd.getData()); try writer.writeAll(cd.getData().str());
} }
} }
}, },
@@ -140,7 +139,24 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
} }
} }
try children(node, opts, writer, page); if (opts.with_frames and el.is(IFrame) != null) {
const frame = el.as(IFrame);
if (frame.getContentDocument()) |doc| {
// A frame's document should always ahave a page, but
// I'm not willing to crash a release build on that assertion.
if (comptime IS_DEBUG) {
std.debug.assert(doc._page != null);
}
if (doc._page) |frame_page| {
try writer.writeByte('\n');
root(doc, opts, writer, frame_page) catch return error.WriteFailed;
try writer.writeByte('\n');
}
}
} else {
try children(node, opts, writer, page);
}
if (!isVoidElement(el)) { if (!isVoidElement(el)) {
try writer.writeAll("</"); try writer.writeAll("</");
try writer.writeAll(el.getTagNameDump()); try writer.writeAll(el.getTagNameDump());
@@ -172,7 +188,11 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
try writer.writeAll(">\n"); try writer.writeAll(">\n");
}, },
.document_fragment => try children(node, opts, writer, page), .document_fragment => try children(node, opts, writer, page),
.attribute => unreachable, .attribute => {
// Not called normally, but can be called via XMLSerializer.serializeToString
// in which case it should return an empty string
try writer.writeAll("");
},
} }
} }
@@ -294,6 +314,12 @@ fn shouldEscapeText(node_: ?*Node) bool {
if (node.is(Node.Element.Html.Script) != null) { if (node.is(Node.Element.Html.Script) != null) {
return false; return false;
} }
// When scripting is enabled, <noscript> is a raw text element per the HTML spec
// (https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments).
// Its text content must not be HTML-escaped during serialization.
if (node.is(Node.Element.Html.Generic)) |generic| {
if (generic._tag == .noscript) return false;
}
return true; return true;
} }
fn writeEscapedText(text: []const u8, writer: *std.Io.Writer) !void { fn writeEscapedText(text: []const u8, writer: *std.Io.Writer) !void {

View File

@@ -251,7 +251,33 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name:
return handleIndexedReturn(T, F, false, local, ret, info, opts); return handleIndexedReturn(T, F, false, local, ret, info, opts);
} }
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime getter: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _getEnumerator(T, local, func, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
if (@typeInfo(F).@"fn".params.len == 2) {
@field(args, "1") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
// need to unwrap this error immediately for when opts.null_as_undefined == true // need to unwrap this error immediately for when opts.null_as_undefined == true
// and we need to compare it to null; // and we need to compare it to null;
const non_error_ret = switch (@typeInfo(@TypeOf(ret))) { const non_error_ret = switch (@typeInfo(@TypeOf(ret))) {
@@ -274,7 +300,7 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime getter: bool
else => ret, else => ret,
}; };
if (comptime getter) { if (comptime with_value) {
info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts)); info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts));
} }
// intercepted // intercepted
@@ -463,6 +489,7 @@ const ReturnValue = struct {
pub const Function = struct { pub const Function = struct {
pub const Opts = struct { pub const Opts = struct {
noop: bool = false,
static: bool = false, static: bool = false,
dom_exception: bool = false, dom_exception: bool = false,
as_typed_array: bool = false, as_typed_array: bool = false,

View File

@@ -43,6 +43,9 @@ env: *Env,
page: *Page, page: *Page,
isolate: js.Isolate, isolate: js.Isolate,
// Per-context microtask queue for isolation between contexts
microtask_queue: *v8.MicrotaskQueue,
// The v8::Global<v8::Context>. When necessary, we can create a v8::Local<<v8::Context>> // The v8::Global<v8::Context>. When necessary, we can create a v8::Local<<v8::Context>>
// from this, and we can free it when the context is done. // from this, and we can free it when the context is done.
handle: v8.Global, handle: v8.Global,
@@ -121,10 +124,6 @@ script_manager: ?*ScriptManager,
// Our macrotasks // Our macrotasks
scheduler: Scheduler, scheduler: Scheduler,
// Prevents us from enqueuing a microtask for this context while we're shutting
// down.
shutting_down: bool = false,
unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {}, unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {},
const ModuleEntry = struct { const ModuleEntry = struct {
@@ -146,16 +145,11 @@ const ModuleEntry = struct {
}; };
pub fn fromC(c_context: *const v8.Context) *Context { pub fn fromC(c_context: *const v8.Context) *Context {
const data = v8.v8__Context__GetEmbedderData(c_context, 1).?; return @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(c_context, 1)));
const big_int = js.BigInt{ .handle = @ptrCast(data) };
return @ptrFromInt(big_int.getUint64());
} }
pub fn fromIsolate(isolate: js.Isolate) *Context { pub fn fromIsolate(isolate: js.Isolate) *Context {
const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?; return fromC(v8.v8__Isolate__GetCurrentContext(isolate.handle).?);
const data = v8.v8__Context__GetEmbedderData(v8_context, 1).?;
const big_int = js.BigInt{ .handle = @ptrCast(data) };
return @ptrFromInt(big_int.getUint64());
} }
pub fn deinit(self: *Context) void { pub fn deinit(self: *Context) void {
@@ -169,21 +163,15 @@ pub fn deinit(self: *Context) void {
}); });
} }
} }
defer self.env.app.arena_pool.release(self.arena);
const env = self.env;
defer env.app.arena_pool.release(self.arena);
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
const entered = self.enter(&hs); const entered = self.enter(&hs);
defer entered.exit(); defer entered.exit();
// We might have microtasks in the isolate that refence this context. The // this can release objects
// only option we have is to run them. But a microtask could queue another
// microtask, so we set the shutting_down flag, so that any such microtask
// will be a noop (this isn't automatic, when v8 calls our microtask callback
// the first thing we'll check is if self.shutting_down == true).
self.shutting_down = true;
self.env.runMicrotasks();
// can release objects
self.scheduler.deinit(); self.scheduler.deinit();
{ {
@@ -244,7 +232,13 @@ pub fn deinit(self: *Context) void {
v8.v8__Global__Reset(global); v8.v8__Global__Reset(global);
} }
} }
v8.v8__Global__Reset(&self.handle); v8.v8__Global__Reset(&self.handle);
env.isolate.notifyContextDisposed();
// There can be other tasks associated with this context that we need to
// purge while the context is still alive.
_ = env.pumpMessageLoop();
v8.v8__MicrotaskQueue__DELETE(self.microtask_queue);
} }
pub fn weakRef(self: *Context, obj: anytype) void { pub fn weakRef(self: *Context, obj: anytype) void {
@@ -606,9 +600,18 @@ pub fn dynamicModuleCallback(
.isolate = self.isolate, .isolate = self.isolate,
}; };
const resource = js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| { const resource = blk: {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" }); const resource_value = js.Value{ .handle = resource_name.?, .local = &local };
return @constCast((local.rejectPromise("Out of memory") catch return null).handle); if (resource_value.isNullOrUndefined()) {
// will only be null / undefined in extreme cases (e.g. WPT tests)
// where you're
break :blk self.page.base();
}
break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" });
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
};
}; };
const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| { const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| {
@@ -992,13 +995,10 @@ pub fn queueSlotchangeDelivery(self: *Context) !void {
// But for these Context microtasks, we want to (a) make sure the context isn't // But for these Context microtasks, we want to (a) make sure the context isn't
// being shut down and (b) that it's entered. // being shut down and (b) that it's entered.
fn enqueueMicrotask(self: *Context, callback: anytype) void { fn enqueueMicrotask(self: *Context, callback: anytype) void {
self.isolate.enqueueMicrotask(struct { // Use context-specific microtask queue instead of isolate queue
v8.v8__MicrotaskQueue__EnqueueMicrotask(self.microtask_queue, self.isolate.handle, struct {
fn run(data: ?*anyopaque) callconv(.c) void { fn run(data: ?*anyopaque) callconv(.c) void {
const ctx: *Context = @ptrCast(@alignCast(data.?)); const ctx: *Context = @ptrCast(@alignCast(data.?));
if (ctx.shutting_down) {
return;
}
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs); const entered = ctx.enter(&hs);
defer entered.exit(); defer entered.exit();
@@ -1008,10 +1008,11 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void {
} }
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void { pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
self.isolate.enqueueMicrotaskFunc(cb); // Use context-specific microtask queue instead of isolate queue
v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle);
} }
pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque) void) !*FinalizerCallback { pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void) !*FinalizerCallback {
const fc = try self.finalizer_callback_pool.create(); const fc = try self.finalizer_callback_pool.create();
fc.* = .{ fc.* = .{
.ctx = self, .ctx = self,
@@ -1031,10 +1032,10 @@ pub const FinalizerCallback = struct {
ctx: *Context, ctx: *Context,
ptr: *anyopaque, ptr: *anyopaque,
global: v8.Global, global: v8.Global,
finalizerFn: *const fn (ptr: *anyopaque) void, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void,
pub fn deinit(self: *FinalizerCallback) void { pub fn deinit(self: *FinalizerCallback) void {
self.finalizerFn(self.ptr); self.finalizerFn(self.ptr, self.ctx.page);
self.ctx.finalizer_callback_pool.destroy(self); self.ctx.finalizer_callback_pool.destroy(self);
} }
}; };

View File

@@ -62,7 +62,8 @@ platform: *const Platform,
// the global isolate // the global isolate
isolate: js.Isolate, isolate: js.Isolate,
contexts: std.ArrayList(*js.Context), contexts: [64]*Context,
context_count: usize,
// just kept around because we need to free it on deinit // just kept around because we need to free it on deinit
isolate_params: *v8.CreateParams, isolate_params: *v8.CreateParams,
@@ -85,6 +86,8 @@ inspector: ?*Inspector,
// which an be created once per isolaet. // which an be created once per isolaet.
private_symbols: PrivateSymbols, private_symbols: PrivateSymbols,
microtask_queues_are_running: bool,
pub const InitOpts = struct { pub const InitOpts = struct {
with_inspector: bool = false, with_inspector: bool = false,
}; };
@@ -203,7 +206,8 @@ pub fn init(app: *App, opts: InitOpts) !Env {
return .{ return .{
.app = app, .app = app,
.context_id = 0, .context_id = 0,
.contexts = .empty, .contexts = undefined,
.context_count = 0,
.isolate = isolate, .isolate = isolate,
.platform = &app.platform, .platform = &app.platform,
.templates = templates, .templates = templates,
@@ -211,15 +215,16 @@ pub fn init(app: *App, opts: InitOpts) !Env {
.inspector = inspector, .inspector = inspector,
.global_template = global_eternal, .global_template = global_eternal,
.private_symbols = private_symbols, .private_symbols = private_symbols,
.microtask_queues_are_running = false,
.eternal_function_templates = eternal_function_templates, .eternal_function_templates = eternal_function_templates,
}; };
} }
pub fn deinit(self: *Env) void { pub fn deinit(self: *Env) void {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
std.debug.assert(self.contexts.items.len == 0); std.debug.assert(self.context_count == 0);
} }
for (self.contexts.items) |ctx| { for (self.contexts[0..self.context_count]) |ctx| {
ctx.deinit(); ctx.deinit();
} }
@@ -228,8 +233,6 @@ pub fn deinit(self: *Env) void {
i.deinit(allocator); i.deinit(allocator);
} }
self.contexts.deinit(allocator);
allocator.free(self.templates); allocator.free(self.templates);
allocator.free(self.eternal_function_templates); allocator.free(self.eternal_function_templates);
self.private_symbols.deinit(); self.private_symbols.deinit();
@@ -249,10 +252,19 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
hs.init(isolate); hs.init(isolate);
defer hs.deinit(); defer hs.deinit();
// Create a per-context microtask queue for isolation
const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?;
errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue);
// Get the global template that was created once per isolate // Get the global template that was created once per isolate
const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?)); const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?));
v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi)); v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi));
const v8_context = v8.v8__Context__New(isolate.handle, global_template, null).?;
const v8_context = v8.v8__Context__New__Config(isolate.handle, &.{
.global_template = global_template,
.global_object = null,
.microtask_queue = microtask_queue,
}).?;
// Create the v8::Context and wrap it in a v8::Global // Create the v8::Context and wrap it in a v8::Global
var context_global: v8.Global = undefined; var context_global: v8.Global = undefined;
@@ -292,6 +304,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
.handle = context_global, .handle = context_global,
.templates = self.templates, .templates = self.templates,
.call_arena = page.call_arena, .call_arena = page.call_arena,
.microtask_queue = microtask_queue,
.script_manager = &page._script_manager, .script_manager = &page._script_manager,
.scheduler = .init(context_arena), .scheduler = .init(context_arena),
.finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator), .finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator),
@@ -300,17 +313,24 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
// Store a pointer to our context inside the v8 context so that, given // Store a pointer to our context inside the v8 context so that, given
// a v8 context, we can get our context out // a v8 context, we can get our context out
const data = isolate.initBigInt(@intFromPtr(context)); v8.v8__Context__SetAlignedPointerInEmbedderData(v8_context, 1, @ptrCast(context));
v8.v8__Context__SetEmbedderData(v8_context, 1, @ptrCast(data.handle));
const count = self.context_count;
if (count >= self.contexts.len) {
return error.TooManyContexts;
}
self.contexts[count] = context;
self.context_count = count + 1;
try self.contexts.append(self.app.allocator, context);
return context; return context;
} }
pub fn destroyContext(self: *Env, context: *Context) void { pub fn destroyContext(self: *Env, context: *Context) void {
for (self.contexts.items, 0..) |ctx, i| { for (self.contexts[0..self.context_count], 0..) |ctx, i| {
if (ctx == context) { if (ctx == context) {
_ = self.contexts.swapRemove(i); // Swap with last element and decrement count
self.context_count -= 1;
self.contexts[i] = self.contexts[self.context_count];
break; break;
} }
} else { } else {
@@ -328,16 +348,26 @@ pub fn destroyContext(self: *Env, context: *Context) void {
} }
context.deinit(); context.deinit();
isolate.notifyContextDisposed();
} }
pub fn runMicrotasks(self: *const Env) void { pub fn runMicrotasks(self: *Env) void {
self.isolate.performMicrotasksCheckpoint(); if (self.microtask_queues_are_running == false) {
const v8_isolate = self.isolate.handle;
self.microtask_queues_are_running = true;
defer self.microtask_queues_are_running = false;
var i: usize = 0;
while (i < self.context_count) : (i += 1) {
const ctx = self.contexts[i];
v8.v8__MicrotaskQueue__PerformCheckpoint(ctx.microtask_queue, v8_isolate);
}
}
} }
pub fn runMacrotasks(self: *Env) !?u64 { pub fn runMacrotasks(self: *Env) !?u64 {
var ms_to_next_task: ?u64 = null; var ms_to_next_task: ?u64 = null;
for (self.contexts.items) |ctx| { for (self.contexts[0..self.context_count]) |ctx| {
if (comptime builtin.is_test == false) { if (comptime builtin.is_test == false) {
// I hate this comptime check as much as you do. But we have tests // I hate this comptime check as much as you do. But we have tests
// which rely on short execution before shutdown. In real world, it's // which rely on short execution before shutdown. In real world, it's
@@ -360,12 +390,31 @@ pub fn runMacrotasks(self: *Env) !?u64 {
return ms_to_next_task; return ms_to_next_task;
} }
pub fn pumpMessageLoop(self: *const Env) bool { pub fn pumpMessageLoop(self: *const Env) void {
var hs: v8.HandleScope = undefined; var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle); v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
defer v8.v8__HandleScope__DESTRUCT(&hs); defer v8.v8__HandleScope__DESTRUCT(&hs);
return v8.v8__Platform__PumpMessageLoop(self.platform.handle, self.isolate.handle, false); const isolate = self.isolate.handle;
const platform = self.platform.handle;
while (v8.v8__Platform__PumpMessageLoop(platform, isolate, false)) {}
}
pub fn hasBackgroundTasks(self: *const Env) bool {
return v8.v8__Isolate__HasPendingBackgroundTasks(self.isolate.handle);
}
pub fn waitForBackgroundTasks(self: *Env) void {
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
defer v8.v8__HandleScope__DESTRUCT(&hs);
const isolate = self.isolate.handle;
const platform = self.platform.handle;
while (v8.v8__Isolate__HasPendingBackgroundTasks(isolate)) {
_ = v8.v8__Platform__PumpMessageLoop(platform, isolate, true);
self.runMicrotasks();
}
} }
pub fn runIdleTasks(self: *const Env) void { pub fn runIdleTasks(self: *const Env) void {

View File

@@ -241,8 +241,6 @@ pub const Session = struct {
msg.ptr, msg.ptr,
msg.len, msg.len,
); );
v8.v8__Isolate__PerformMicrotaskCheckpoint(isolate);
} }
// Gets a value by object ID regardless of which context it is in. // Gets a value by object ID regardless of which context it is in.

View File

@@ -41,18 +41,6 @@ pub fn exit(self: Isolate) void {
v8.v8__Isolate__Exit(self.handle); v8.v8__Isolate__Exit(self.handle);
} }
pub fn performMicrotasksCheckpoint(self: Isolate) void {
v8.v8__Isolate__PerformMicrotaskCheckpoint(self.handle);
}
pub fn enqueueMicrotask(self: Isolate, callback: anytype, data: anytype) void {
v8.v8__Isolate__EnqueueMicrotask(self.handle, callback, data);
}
pub fn enqueueMicrotaskFunc(self: Isolate, function: js.Function) void {
v8.v8__Isolate__EnqueueMicrotaskFunc(self.handle, function.handle);
}
pub fn lowMemoryNotification(self: Isolate) void { pub fn lowMemoryNotification(self: Isolate) void {
v8.v8__Isolate__LowMemoryNotification(self.handle); v8.v8__Isolate__LowMemoryNotification(self.handle);
} }

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const Page = @import("../Page.zig");
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const string = @import("../../string.zig"); const string = @import("../../string.zig");
@@ -81,8 +82,14 @@ pub fn createTypedArray(self: *const Local, comptime array_type: js.ArrayType, s
return .init(self, size); return .init(self, size);
} }
pub fn runMacrotasks(self: *const Local) void {
const env = self.ctx.env;
env.pumpMessageLoop();
env.runMicrotasks(); // macrotasks can cause microtasks to queue
}
pub fn runMicrotasks(self: *const Local) void { pub fn runMicrotasks(self: *const Local) void {
self.isolate.performMicrotasksCheckpoint(); self.ctx.env.runMicrotasks();
} }
// == Executors == // == Executors ==
@@ -216,7 +223,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc); try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc);
} }
conditionallyFlagHandoff(value); conditionallyReference(value);
if (@hasDecl(JsApi.Meta, "weak")) { if (@hasDecl(JsApi.Meta, "weak")) {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
std.debug.assert(JsApi.Meta.weak == true); std.debug.assert(JsApi.Meta.weak == true);
@@ -322,6 +329,7 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
}, },
inline inline
js.Array,
js.Function, js.Function,
js.Object, js.Object,
js.Promise, js.Promise,
@@ -1061,7 +1069,7 @@ const Resolved = struct {
class_id: u16, class_id: u16,
prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry, prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry,
finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null, finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null,
finalizer_from_zig: ?*const fn (ptr: *anyopaque) void = null, finalizer_from_zig: ?*const fn (ptr: *anyopaque, page: *Page) void = null,
}; };
pub fn resolveValue(value: anytype) Resolved { pub fn resolveValue(value: anytype) Resolved {
const T = bridge.Struct(@TypeOf(value)); const T = bridge.Struct(@TypeOf(value));
@@ -1100,14 +1108,14 @@ fn resolveT(comptime T: type, value: *anyopaque) Resolved {
}; };
} }
fn conditionallyFlagHandoff(value: anytype) void { fn conditionallyReference(value: anytype) void {
const T = bridge.Struct(@TypeOf(value)); const T = bridge.Struct(@TypeOf(value));
if (@hasField(T, "_v8_handoff")) { if (@hasDecl(T, "acquireRef")) {
value._v8_handoff = true; value.acquireRef();
return; return;
} }
if (@hasField(T, "_proto")) { if (@hasField(T, "_proto")) {
conditionallyFlagHandoff(value._proto); conditionallyReference(value._proto);
} }
} }
@@ -1208,13 +1216,20 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman
gop.value_ptr.* = {}; gop.value_ptr.* = {};
} }
const names_arr = js_obj.getOwnPropertyNames();
const len = names_arr.len();
if (depth > 20) { if (depth > 20) {
return writer.writeAll("...deeply nested object..."); return writer.writeAll("...deeply nested object...");
} }
const own_len = js_obj.getOwnPropertyNames().len();
const names_arr = js_obj.getOwnPropertyNames() catch {
return writer.writeAll("...invalid object...");
};
const len = names_arr.len();
const own_len = blk: {
const own_names = js_obj.getOwnPropertyNames() catch break :blk 0;
break :blk own_names.len();
};
if (own_len == 0) { if (own_len == 0) {
const js_val_str = try js_val.toStringSlice(); const js_val_str = try js_val.toStringSlice();
if (js_val_str.len > 2000) { if (js_val_str.len > 2000) {

View File

@@ -129,8 +129,14 @@ pub fn isNullOrUndefined(self: Object) bool {
return v8.v8__Value__IsNullOrUndefined(@ptrCast(self.handle)); return v8.v8__Value__IsNullOrUndefined(@ptrCast(self.handle));
} }
pub fn getOwnPropertyNames(self: Object) js.Array { pub fn getOwnPropertyNames(self: Object) !js.Array {
const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle).?; const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle) orelse {
// This is almost always a fatal error case. Either we're in some exception
// and things are messy, or we're shutting down, or someone has messed up
// the object (like some WPT tests do).
return error.TypeError;
};
return .{ return .{
.local = self.local, .local = self.local,
.handle = handle, .handle = handle,
@@ -145,8 +151,11 @@ pub fn getPropertyNames(self: Object) js.Array {
}; };
} }
pub fn nameIterator(self: Object) NameIterator { pub fn nameIterator(self: Object) !NameIterator {
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle).?; const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle) orelse {
// see getOwnPropertyNames above
return error.TypeError;
};
const count = v8.v8__Array__Length(handle); const count = v8.v8__Array__Length(handle);
return .{ return .{

View File

@@ -282,9 +282,13 @@ fn hasNamedIndexedGetter(comptime JsApi: type) bool {
fn countExternalReferences() comptime_int { fn countExternalReferences() comptime_int {
@setEvalBranchQuota(100_000); @setEvalBranchQuota(100_000);
// +1 for the illegal constructor callback var count: comptime_int = 0;
var count: comptime_int = 1;
var has_non_template_property: bool = false; // +1 for the illegal constructor callback shared by various types
count += 1;
// +1 for the noop function shared by various types
count += 1;
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
// Constructor (only if explicit) // Constructor (only if explicit)
@@ -304,17 +308,18 @@ fn countExternalReferences() comptime_int {
const T = @TypeOf(value); const T = @TypeOf(value);
if (T == bridge.Accessor) { if (T == bridge.Accessor) {
count += 1; // getter count += 1; // getter
if (value.setter != null) count += 1; // setter if (value.setter != null) {
count += 1;
}
} else if (T == bridge.Function) { } else if (T == bridge.Function) {
count += 1; count += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) { } else if (T == bridge.Iterator) {
count += 1; count += 1;
} else if (T == bridge.Indexed) { } else if (T == bridge.Indexed) {
count += 1; count += 1;
if (value.enumerator != null) {
count += 1;
}
} else if (T == bridge.NamedIndexed) { } else if (T == bridge.NamedIndexed) {
count += 1; // getter count += 1; // getter
if (value.setter != null) count += 1; if (value.setter != null) count += 1;
@@ -323,10 +328,6 @@ fn countExternalReferences() comptime_int {
} }
} }
if (has_non_template_property) {
count += 1;
}
// In debug mode, add unknown property callbacks for types without NamedIndexed // In debug mode, add unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
@@ -346,7 +347,8 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback)); references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback));
idx += 1; idx += 1;
var has_non_template_property = false; references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction));
idx += 1;
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
if (@hasDecl(JsApi, "constructor")) { if (@hasDecl(JsApi, "constructor")) {
@@ -373,16 +375,16 @@ fn collectExternalReferences() [countExternalReferences()]isize {
} else if (T == bridge.Function) { } else if (T == bridge.Function) {
references[idx] = @bitCast(@intFromPtr(value.func)); references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1; idx += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) { } else if (T == bridge.Iterator) {
references[idx] = @bitCast(@intFromPtr(value.func)); references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1; idx += 1;
} else if (T == bridge.Indexed) { } else if (T == bridge.Indexed) {
references[idx] = @bitCast(@intFromPtr(value.getter)); references[idx] = @bitCast(@intFromPtr(value.getter));
idx += 1; idx += 1;
if (value.enumerator) |enumerator| {
references[idx] = @bitCast(@intFromPtr(enumerator));
idx += 1;
}
} else if (T == bridge.NamedIndexed) { } else if (T == bridge.NamedIndexed) {
references[idx] = @bitCast(@intFromPtr(value.getter)); references[idx] = @bitCast(@intFromPtr(value.getter));
idx += 1; idx += 1;
@@ -398,11 +400,6 @@ fn collectExternalReferences() [countExternalReferences()]isize {
} }
} }
if (has_non_template_property) {
references[idx] = @bitCast(@intFromPtr(&bridge.Property.getter));
idx += 1;
}
// In debug mode, collect unknown property callbacks for types without NamedIndexed // In debug mode, collect unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
@@ -486,8 +483,8 @@ pub fn countInternalFields(comptime JsApi: type) u8 {
// Attaches JsApi members to the prototype template (normal case) // Attaches JsApi members to the prototype template (normal case)
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void { fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
const target = v8.v8__FunctionTemplate__PrototypeTemplate(template);
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template); const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
const declarations = @typeInfo(JsApi).@"struct".decls; const declarations = @typeInfo(JsApi).@"struct".decls;
var has_named_index_getter = false; var has_named_index_getter = false;
@@ -505,14 +502,14 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
if (value.static) { if (value.static) {
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback); v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
} else { } else {
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback); v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback);
} }
} else { } else {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
std.debug.assert(value.static == false); std.debug.assert(value.static == false);
} }
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?); const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(target, js_name, getter_callback, setter_callback); v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback);
} }
}, },
bridge.Function => { bridge.Function => {
@@ -521,16 +518,16 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
if (value.static) { if (value.static) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None); v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
} else { } else {
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None); v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None);
} }
}, },
bridge.Indexed => { bridge.Indexed => {
var configuration: v8.IndexedPropertyHandlerConfiguration = .{ var configuration: v8.IndexedPropertyHandlerConfiguration = .{
.getter = value.getter, .getter = value.getter,
.enumerator = value.enumerator,
.setter = null, .setter = null,
.query = null, .query = null,
.deleter = null, .deleter = null,
.enumerator = null,
.definer = null, .definer = null,
.descriptor = null, .descriptor = null,
.data = null, .data = null,
@@ -559,7 +556,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
v8.v8__Symbol__GetAsyncIterator(isolate) v8.v8__Symbol__GetAsyncIterator(isolate)
else else
v8.v8__Symbol__GetIterator(isolate); v8.v8__Symbol__GetIterator(isolate);
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None); v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None);
}, },
bridge.Property => { bridge.Property => {
const js_value = switch (value.value) { const js_value = switch (value.value) {
@@ -568,22 +565,14 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
}; };
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
if (value.template == false) { {
// not defined on the template, only on the instance. This const flags = if (value.readonly) v8.ReadOnly + v8.DontDelete else 0;
// is like an Accessor, but because the value is known at v8.v8__Template__Set(@ptrCast(prototype), js_name, js_value, flags);
// compile time, we skip _a lot_ of code and quickly return }
// the hard-coded value
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ if (value.template) {
.callback = bridge.Property.getter, // apply it both to the type itself (e.g. Node.Elem)
.data = js_value,
.side_effect_type = v8.kSideEffectType_HasSideEffectToReceiver,
}));
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback);
} else {
// apply it both to the type itself
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete); v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
// and to instances of the type
v8.v8__Template__Set(@ptrCast(target), js_name, js_value, v8.ReadOnly + v8.DontDelete);
} }
}, },
bridge.Constructor => {}, // already handled in generateConstructor bridge.Constructor => {}, // already handled in generateConstructor

View File

@@ -46,8 +46,8 @@ pub fn Builder(comptime T: type) type {
return Function.init(T, func, opts); return Function.init(T, func, opts);
} }
pub fn indexed(comptime getter_func: anytype, comptime opts: Indexed.Opts) Indexed { pub fn indexed(comptime getter_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
return Indexed.init(T, getter_func, opts); return Indexed.init(T, getter_func, enumerator_func, opts);
} }
pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed { pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed {
@@ -104,11 +104,11 @@ pub fn Builder(comptime T: type) type {
return entries; return entries;
} }
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool) void) Finalizer { pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, page: *Page) void) Finalizer {
return .{ return .{
.from_zig = struct { .from_zig = struct {
fn wrap(ptr: *anyopaque) void { fn wrap(ptr: *anyopaque, page: *Page) void {
func(@ptrCast(@alignCast(ptr)), true); func(@ptrCast(@alignCast(ptr)), true, page);
} }
}.wrap, }.wrap,
@@ -120,7 +120,7 @@ pub fn Builder(comptime T: type) type {
const ctx = fc.ctx; const ctx = fc.ctx;
const value_ptr = fc.ptr; const value_ptr = fc.ptr;
if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false); func(@ptrCast(@alignCast(value_ptr)), false, ctx.page);
ctx.release(value_ptr); ctx.release(value_ptr);
} else { } else {
// A bit weird, but v8 _requires_ that we release it // A bit weird, but v8 _requires_ that we release it
@@ -160,6 +160,7 @@ pub const Constructor = struct {
pub const Function = struct { pub const Function = struct {
static: bool, static: bool,
arity: usize, arity: usize,
noop: bool = false,
cache: ?Caller.Function.Opts.Caching = null, cache: ?Caller.Function.Opts.Caching = null,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void, func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
@@ -168,7 +169,7 @@ pub const Function = struct {
.cache = opts.cache, .cache = opts.cache,
.static = opts.static, .static = opts.static,
.arity = getArity(@TypeOf(func)), .arity = getArity(@TypeOf(func)),
.func = struct { .func = if (opts.noop) noopFunction else struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
Caller.Function.call(T, handle.?, func, opts); Caller.Function.call(T, handle.?, func, opts);
} }
@@ -176,6 +177,8 @@ pub const Function = struct {
}; };
} }
pub fn noopFunction(_: ?*const v8.FunctionCallbackInfo) callconv(.c) void {}
fn getArity(comptime T: type) usize { fn getArity(comptime T: type) usize {
var count: usize = 0; var count: usize = 0;
var params = @typeInfo(T).@"fn".params; var params = @typeInfo(T).@"fn".params;
@@ -227,26 +230,44 @@ pub const Accessor = struct {
pub const Indexed = struct { pub const Indexed = struct {
getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8, getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
const Opts = struct { const Opts = struct {
as_typed_array: bool = false, as_typed_array: bool = false,
null_as_undefined: bool = false, null_as_undefined: bool = false,
}; };
fn init(comptime T: type, comptime getter: anytype, comptime opts: Opts) Indexed { fn init(comptime T: type, comptime getter: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed {
return .{ .getter = struct { var indexed = Indexed{
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { .enumerator = null,
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; .getter = struct {
var caller: Caller = undefined; fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
caller.init(v8_isolate); const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
defer caller.deinit(); var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
return caller.getIndex(T, getter, idx, handle.?, .{ return caller.getIndex(T, getter, idx, handle.?, .{
.as_typed_array = opts.as_typed_array, .as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined, .null_as_undefined = opts.null_as_undefined,
}); });
} }
}.wrap }; }.wrap,
};
if (@typeInfo(@TypeOf(enumerator)) != .null) {
indexed.enumerator = struct {
fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
return caller.getEnumerator(T, enumerator, handle.?, .{});
}
}.wrap;
}
return indexed;
} }
}; };
@@ -367,6 +388,7 @@ pub const Callable = struct {
pub const Property = struct { pub const Property = struct {
value: Value, value: Value,
template: bool, template: bool,
readonly: bool,
const Value = union(enum) { const Value = union(enum) {
null, null,
@@ -378,27 +400,22 @@ pub const Property = struct {
const Opts = struct { const Opts = struct {
template: bool, template: bool,
readonly: bool = true,
}; };
fn init(value: Value, opts: Opts) Property { fn init(value: Value, opts: Opts) Property {
return .{ return .{
.value = value, .value = value,
.template = opts.template, .template = opts.template,
.readonly = opts.readonly,
}; };
} }
pub fn getter(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const value = v8.v8__FunctionCallbackInfo__Data(handle.?);
var rv: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(handle.?, &rv);
v8.v8__ReturnValue__Set(rv, value);
}
}; };
const Finalizer = struct { const Finalizer = struct {
// The finalizer wrapper when called fro Zig. This is only called on // The finalizer wrapper when called fro Zig. This is only called on
// Context.deinit // Context.deinit
from_zig: *const fn (ctx: *anyopaque) void, from_zig: *const fn (ctx: *anyopaque, page: *Page) void,
// The finalizer wrapper when called from V8. This may never be called // The finalizer wrapper when called from V8. This may never be called
// (hence why we fallback to calling in Context.denit). If it is called, // (hence why we fallback to calling in Context.denit). If it is called,
@@ -713,6 +730,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/css/CSSStyleRule.zig"), @import("../webapi/css/CSSStyleRule.zig"),
@import("../webapi/css/CSSStyleSheet.zig"), @import("../webapi/css/CSSStyleSheet.zig"),
@import("../webapi/css/CSSStyleProperties.zig"), @import("../webapi/css/CSSStyleProperties.zig"),
@import("../webapi/css/FontFaceSet.zig"),
@import("../webapi/css/MediaQueryList.zig"), @import("../webapi/css/MediaQueryList.zig"),
@import("../webapi/css/StyleSheetList.zig"), @import("../webapi/css/StyleSheetList.zig"),
@import("../webapi/Document.zig"), @import("../webapi/Document.zig"),
@@ -857,6 +875,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/IdleDeadline.zig"), @import("../webapi/IdleDeadline.zig"),
@import("../webapi/Blob.zig"), @import("../webapi/Blob.zig"),
@import("../webapi/File.zig"), @import("../webapi/File.zig"),
@import("../webapi/FileReader.zig"),
@import("../webapi/Screen.zig"), @import("../webapi/Screen.zig"),
@import("../webapi/VisualViewport.zig"), @import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"), @import("../webapi/PerformanceObserver.zig"),
@@ -865,6 +884,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/navigation/NavigationActivation.zig"), @import("../webapi/navigation/NavigationActivation.zig"),
@import("../webapi/canvas/CanvasRenderingContext2D.zig"), @import("../webapi/canvas/CanvasRenderingContext2D.zig"),
@import("../webapi/canvas/WebGLRenderingContext.zig"), @import("../webapi/canvas/WebGLRenderingContext.zig"),
@import("../webapi/canvas/OffscreenCanvas.zig"),
@import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"),
@import("../webapi/SubtleCrypto.zig"), @import("../webapi/SubtleCrypto.zig"),
@import("../webapi/Selection.zig"), @import("../webapi/Selection.zig"),
@import("../webapi/ImageData.zig"), @import("../webapi/ImageData.zig"),

View File

@@ -145,7 +145,7 @@ fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error
}, },
.cdata => |cd| { .cdata => |cd| {
if (node.is(Node.CData.Text)) |_| { if (node.is(Node.CData.Text)) |_| {
var text = cd.getData(); var text = cd.getData().str();
if (state.pre_node) |pre| { if (state.pre_node) |pre| {
if (node.parentNode() == pre and node.nextSibling() == null) { if (node.parentNode() == pre and node.nextSibling() == null) {
text = std.mem.trimRight(u8, text, " \t\r\n"); text = std.mem.trimRight(u8, text, " \t\r\n");

View File

@@ -88,3 +88,15 @@
ctx.putImageData(imageData, 0, 0, 0, 0, 5, 5); ctx.putImageData(imageData, 0, 0, 0, 0, 5, 5);
} }
</script> </script>
<script id="getter">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
testing.expectEqual('10px sans-serif', ctx.font);
ctx.font = 'bold 48px serif'
testing.expectEqual('bold 48px serif', ctx.font);
}
</script>

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=OffscreenCanvas>
{
const canvas = new OffscreenCanvas(256, 256);
testing.expectEqual(true, canvas instanceof OffscreenCanvas);
testing.expectEqual(canvas.width, 256);
testing.expectEqual(canvas.height, 256);
}
</script>
<script id=OffscreenCanvas#width>
{
const canvas = new OffscreenCanvas(100, 200);
testing.expectEqual(canvas.width, 100);
canvas.width = 300;
testing.expectEqual(canvas.width, 300);
}
</script>
<script id=OffscreenCanvas#height>
{
const canvas = new OffscreenCanvas(100, 200);
testing.expectEqual(canvas.height, 200);
canvas.height = 400;
testing.expectEqual(canvas.height, 400);
}
</script>
<script id=OffscreenCanvas#getContext>
{
const canvas = new OffscreenCanvas(64, 64);
const ctx = canvas.getContext("2d");
testing.expectEqual(true, ctx instanceof OffscreenCanvasRenderingContext2D);
// We can't really test rendering but let's try to call it at least.
ctx.fillRect(0, 0, 10, 10);
}
</script>
<script id=OffscreenCanvas#convertToBlob>
{
const canvas = new OffscreenCanvas(64, 64);
const promise = canvas.convertToBlob();
testing.expectEqual(true, promise instanceof Promise);
// The promise should resolve to a Blob (even if empty)
promise.then(blob => {
testing.expectEqual(true, blob instanceof Blob);
testing.expectEqual(blob.size, 0); // Empty since no rendering
});
}
</script>
<script id=HTMLCanvasElement#transferControlToOffscreen>
{
const htmlCanvas = document.createElement("canvas");
htmlCanvas.width = 128;
htmlCanvas.height = 96;
const offscreen = htmlCanvas.transferControlToOffscreen();
testing.expectEqual(true, offscreen instanceof OffscreenCanvas);
testing.expectEqual(offscreen.width, 128);
testing.expectEqual(offscreen.height, 96);
}
</script>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="document_fonts_exists">
{
testing.expectTrue(document.fonts !== undefined);
testing.expectTrue(document.fonts !== null);
}
</script>
<script id="document_fonts_same_instance">
{
// Should return same instance each time
const f1 = document.fonts;
const f2 = document.fonts;
testing.expectTrue(f1 === f2);
}
</script>
<script id="document_fonts_status">
{
testing.expectEqual('loaded', document.fonts.status);
}
</script>
<script id="document_fonts_size">
{
testing.expectEqual(0, document.fonts.size);
}
</script>
<script id="document_fonts_ready_is_promise">
{
const ready = document.fonts.ready;
testing.expectTrue(ready instanceof Promise);
}
</script>
<script id="document_fonts_ready_resolves">
{
let resolved = false;
document.fonts.ready.then(() => { resolved = true; });
// Promise resolution is async; just confirm .then() does not throw
testing.expectTrue(typeof document.fonts.ready.then === 'function');
}
</script>
<script id="document_fonts_check">
{
testing.expectTrue(document.fonts.check('16px sans-serif'));
}
</script>
<script id="document_fonts_constructor_name">
{
testing.expectEqual('FontFaceSet', document.fonts.constructor.name);
}
</script>

View File

@@ -12,7 +12,7 @@
testing.expectEqual(10, document.childNodes[0].nodeType); testing.expectEqual(10, document.childNodes[0].nodeType);
testing.expectEqual(null, document.parentNode); testing.expectEqual(null, document.parentNode);
testing.expectEqual(undefined, document.getCurrentScript); testing.expectEqual(undefined, document.getCurrentScript);
testing.expectEqual("http://127.0.0.1:9582/src/browser/tests/document/document.html", document.URL); testing.expectEqual(testing.BASE_URL + 'document/document.html', document.URL);
testing.expectEqual(window, document.defaultView); testing.expectEqual(window, document.defaultView);
testing.expectEqual(false, document.hidden); testing.expectEqual(false, document.hidden);
testing.expectEqual("visible", document.visibilityState); testing.expectEqual("visible", document.visibilityState);
@@ -57,7 +57,7 @@
testing.expectEqual('CSS1Compat', document.compatMode); testing.expectEqual('CSS1Compat', document.compatMode);
testing.expectEqual(document.URL, document.documentURI); testing.expectEqual(document.URL, document.documentURI);
testing.expectEqual('', document.referrer); testing.expectEqual('', document.referrer);
testing.expectEqual('127.0.0.1', document.domain); testing.expectEqual(testing.HOST, document.domain);
</script> </script>
<script id=programmatic_document_metadata> <script id=programmatic_document_metadata>
@@ -70,7 +70,7 @@
testing.expectEqual('CSS1Compat', doc.compatMode); testing.expectEqual('CSS1Compat', doc.compatMode);
testing.expectEqual('', doc.referrer); testing.expectEqual('', doc.referrer);
// Programmatic document should have empty domain (no URL/origin) // Programmatic document should have empty domain (no URL/origin)
testing.expectEqual('127.0.0.1', doc.domain); testing.expectEqual(testing.HOST, doc.domain);
</script> </script>
<!-- Test anchors and links --> <!-- Test anchors and links -->

View File

@@ -27,7 +27,9 @@
testing.expectEqual(expected.length, result.length); testing.expectEqual(expected.length, result.length);
testing.expectEqual(expected, Array.from(result).map((e) => e.textContent)); testing.expectEqual(expected, Array.from(result).map((e) => e.textContent));
testing.expectEqual(expected, Array.from(result.values()).map((e) => e.textContent)); testing.expectEqual(expected, Array.from(result.values()).map((e) => e.textContent));
testing.expectEqual(expected.map((e, i) => i), Array.from(result.keys())); testing.expectEqual(expected.map((e, i) => i), Array.from(result.keys()));
testing.expectEqual(expected.map((e, i) => i.toString()), Object.keys(result));
} }
</script> </script>
@@ -440,3 +442,29 @@
} }
</script> </script>
<script id=iterator_list_lifetime>
// This test is intended to ensure that a list remains alive as long as it
// must, i.e. as long as any iterator referencing the list is alive.
// This test depends on being able to force the v8 GC to cleanup, which
// we have no way of controlling. At worst, the test will pass without
// actually testing correct lifetime. But it was at least manually verified
// for me that this triggers plenty of GCs.
const expected = Array.from(document.querySelectorAll('*')).length;
{
let keys = [];
// Phase 1: Create many lists+iterators to fill up the arena pool
for (let i = 0; i < 1000; i++) {
let list = document.querySelectorAll('*');
keys.push(list.keys());
// Create an Event every iteration to compete for arenas
new Event('arena_compete');
}
for (let k of keys) {
const result = Array.from(k);
testing.expectEqual(expected, result.length);
}
}
</script>

View File

@@ -154,3 +154,11 @@
testing.expectEqual(true, typeof div.style.getPropertyPriority === 'function'); testing.expectEqual(true, typeof div.style.getPropertyPriority === 'function');
} }
</script> </script>
<div id=crash1 style="background-position: 5% .1em"></div>
<script id="crash_case_1">
{
testing.expectEqual('5% .1em', $('#crash1').style.backgroundPosition);
}
</script>

View File

@@ -11,11 +11,11 @@
<script id=empty_href> <script id=empty_href>
testing.expectEqual('', $('#a0').href); testing.expectEqual('', $('#a0').href);
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/anchor1.html', $('#a1').href); testing.expectEqual(testing.BASE_URL + 'element/anchor1.html', $('#a1').href);
testing.expectEqual('http://127.0.0.1:9582/hello/world/anchor2.html', $('#a2').href); testing.expectEqual(testing.ORIGIN + 'hello/world/anchor2.html', $('#a2').href);
testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href); testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href);
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', $('#link').href); testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href);
</script> </script>
<script id=dynamic_anchor_defaults> <script id=dynamic_anchor_defaults>
@@ -129,7 +129,7 @@
testing.expectEqual('https://foo.bar/?q=bar#frag', link.href); testing.expectEqual('https://foo.bar/?q=bar#frag', link.href);
link.href = 'foo'; link.href = 'foo';
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', link.href); testing.expectEqual(testing.BASE_URL + 'element/html/foo', link.href);
testing.expectEqual('', link.type); testing.expectEqual('', link.type);
link.type = 'text/html'; link.type = 'text/html';
@@ -245,3 +245,11 @@
testing.expectEqual('', b.toString()); testing.expectEqual('', b.toString());
} }
</script> </script>
<script id=url_encode>
{
let a = document.createElement('a');
a.href = 'over 9000!';
testing.expectEqual(testing.BASE_URL + 'element/html/over%209000!', a.href);
}
</script>

View File

@@ -143,6 +143,29 @@
} }
</script> </script>
<script id="js_setter_null_clears_listener">
{
// Setting an event handler property to null must silently clear it (not throw).
// Browsers also accept undefined and non-function values without throwing.
const div = document.createElement('div');
div.onload = () => 42;
testing.expectEqual('function', typeof div.onload);
// Setting to null removes the listener; getter returns null
div.onload = null;
testing.expectEqual(null, div.onload);
div.onerror = () => {};
div.onerror = null;
testing.expectEqual(null, div.onerror);
div.onclick = () => {};
div.onclick = null;
testing.expectEqual(null, div.onclick);
}
</script>
<script id="different_event_types_independent"> <script id="different_event_types_independent">
{ {
// Test that different event types are stored independently // Test that different event types are stored independently

View File

@@ -32,12 +32,12 @@
img.src = 'test.png'; img.src = 'test.png';
// src property returns resolved absolute URL // src property returns resolved absolute URL
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/test.png', img.src); testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.src);
// getAttribute returns the raw attribute value // getAttribute returns the raw attribute value
testing.expectEqual('test.png', img.getAttribute('src')); testing.expectEqual('test.png', img.getAttribute('src'));
img.src = '/absolute/path.png'; img.src = '/absolute/path.png';
testing.expectEqual('http://127.0.0.1:9582/absolute/path.png', img.src); testing.expectEqual(testing.ORIGIN + 'absolute/path.png', img.src);
testing.expectEqual('/absolute/path.png', img.getAttribute('src')); testing.expectEqual('/absolute/path.png', img.getAttribute('src'));
img.src = 'https://example.com/image.png'; img.src = 'https://example.com/image.png';
@@ -114,48 +114,15 @@
} }
</script> </script>
<script id="load-trigger-event"> <body></body>
<script id="img-load-event">
{ {
// An img fires a load event when src is set.
const img = document.createElement("img"); const img = document.createElement("img");
let count = 0; let result = false;
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(true, count < 3);
count++;
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(img, target);
});
for (let i = 0; i < 3; i++) {
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
testing.expectEqual("https://cdn.lightpanda.io/website/assets/images/docs/hn.png", img.src);
}
// Make sure count is incremented asynchronously.
testing.expectEqual(0, count);
}
</script>
<img
id="inline-img"
src="https://cdn.lightpanda.io/website/assets/images/docs/hn.png"
onload="(() => testing.expectEqual(true, true))()"
/>
<script id="inline-on-load">
{
const img = document.getElementById("inline-img");
testing.expectEqual(true, img.onload instanceof Function);
// Also call inline to double check.
img.onload();
// Make sure ones attached with `addEventListener` also executed.
testing.async(async () => { testing.async(async () => {
const result = await new Promise(resolve => { await new Promise(resolve => {
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => { img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles); testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble); testing.expectEqual(false, cancelBubble);
@@ -163,12 +130,46 @@
testing.expectEqual(false, composed); testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted); testing.expectEqual(true, isTrusted);
testing.expectEqual(img, target); testing.expectEqual(img, target);
result = true;
return resolve(true); return resolve();
}); });
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
}); });
testing.expectEqual(true, result);
}); });
testing.eventually(() => testing.expectEqual(true, result));
} }
</script> </script>
<script id="img-no-load-without-src">
{
// An img without src should not fire a load event.
let fired = false;
const img = document.createElement("img");
img.addEventListener("load", () => { fired = true; });
document.body.appendChild(img);
testing.eventually(() => testing.expectEqual(false, fired));
}
</script>
<script id="lazy-src-set">
{
// Append to DOM first, then set src — load should still fire.
const img = document.createElement("img");
let result = false;
img.onload = () => result = true;
document.body.appendChild(img);
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
testing.eventually(() => testing.expectEqual(true, result));
}
</script>
<script id=url_encode>
{
let img = document.createElement('img');
img.src = 'over 9000!?hello=world !';
testing.expectEqual('over 9000!?hello=world !', img.getAttribute('src'));
testing.expectEqual(testing.BASE_URL + 'element/html/over%209000!?hello=world%20!', img.src);
}
</script>

View File

@@ -57,9 +57,9 @@
testing.expectEqual('', input.src); testing.expectEqual('', input.src);
input.src = 'foo' input.src = 'foo'
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', input.src); testing.expectEqual(testing.BASE_URL + 'element/html/foo', input.src);
input.src = '-3' input.src = '-3'
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/-3', input.src); testing.expectEqual(testing.BASE_URL + 'element/html/-3', input.src);
input.src = '' input.src = ''
} }
</script> </script>

View File

@@ -8,7 +8,7 @@
testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href); testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href);
l2.href = '/over/9000'; l2.href = '/over/9000';
testing.expectEqual('http://127.0.0.1:9582/over/9000', l2.href); testing.expectEqual(testing.ORIGIN + 'over/9000', l2.href);
l2.crossOrigin = 'nope'; l2.crossOrigin = 'nope';
testing.expectEqual('anonymous', l2.crossOrigin); testing.expectEqual('anonymous', l2.crossOrigin);
@@ -19,3 +19,68 @@
l2.crossOrigin = ''; l2.crossOrigin = '';
testing.expectEqual('anonymous', l2.crossOrigin); testing.expectEqual('anonymous', l2.crossOrigin);
</script> </script>
<script id="link-load-event">
{
// A link with rel=stylesheet and a non-empty href fires a load event when appended to the DOM
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://lightpanda.io/opensource-browser/15';
testing.async(async () => {
const result = await new Promise(resolve => {
link.addEventListener('load', ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(link, target);
resolve(true);
});
document.head.appendChild(link);
});
testing.expectEqual(true, result);
});
testing.expectEqual(true, true);
}
</script>
<script id="link-no-load-without-href">
{
// A link with rel=stylesheet but no href should not fire a load event
let fired = false;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.addEventListener('load', () => { fired = true; });
document.head.appendChild(link);
testing.eventually(() => testing.expectEqual(false, fired));
}
</script>
<script id="link-no-load-wrong-rel">
{
// A link without rel=stylesheet should not fire a load event
let fired = false;
const link = document.createElement('link');
link.href = 'https://lightpanda.io/opensource-browser/15';
link.addEventListener('load', () => { fired = true; });
document.head.appendChild(link);
testing.eventually(() => testing.expectEqual(false, fired));
}
</script>
<script id="lazy-href-set">
{
let result = false;
const link = document.createElement("link");
link.rel = "stylesheet";
link.onload = () => result = true;
// Append to DOM,
document.head.appendChild(link);
// then set href.
link.href = 'https://lightpanda.io/opensource-browser/15';
testing.eventually(() => testing.expectEqual(true, result));
}
</script>

View File

@@ -238,7 +238,7 @@
testing.expectEqual('', audio.src); testing.expectEqual('', audio.src);
audio.src = 'test.mp3'; audio.src = 'test.mp3';
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/test.mp3', audio.src); testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.src);
} }
</script> </script>
@@ -248,7 +248,7 @@
testing.expectEqual('', video.poster); testing.expectEqual('', video.poster);
video.poster = 'poster.jpg'; video.poster = 'poster.jpg';
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/poster.jpg', video.poster); testing.expectEqual(testing.BASE_URL + 'element/html/poster.jpg', video.poster);
} }
</script> </script>

View File

@@ -29,6 +29,12 @@
testing.expectEqual('Text 3', $('#opt3').text) testing.expectEqual('Text 3', $('#opt3').text)
</script> </script>
<script id="text_set">
$('#opt1').text = 'New Text 1'
testing.expectEqual('New Text 1', $('#opt1').text)
testing.expectEqual('New Text 1', $('#opt1').textContent)
</script>
<script id="selected"> <script id="selected">
testing.expectEqual(false, $('#opt1').selected) testing.expectEqual(false, $('#opt1').selected)
testing.expectEqual(true, $('#opt2').selected) testing.expectEqual(true, $('#opt2').selected)

View File

@@ -2,11 +2,28 @@
<script src="../../../testing.js"></script> <script src="../../../testing.js"></script>
<script id="script"> <script id="script">
{ {
let s = document.createElement('script'); let dom_load = false;
testing.expectEqual('', s.src); let attribute_load = false;
s.src = '/over.9000.js'; let s = document.createElement('script');
testing.expectEqual('http://127.0.0.1:9582/over.9000.js', s.src); document.documentElement.addEventListener('load', (e) => {
testing.expectEqual(s, e.target);
dom_load = true;
}, true);
testing.expectEqual('', s.src);
s.onload = function(e) {
testing.expectEqual(s, e.target);
attribute_load = true;
} }
s.src = 'empty.js';
testing.expectEqual(testing.BASE_URL + 'element/html/script/empty.js', s.src);
document.head.appendChild(s);
testing.eventually(() => {
testing.expectEqual(true, dom_load);
testing.expectEqual(true, attribute_load);
});
}
</script> </script>

View File

@@ -106,3 +106,28 @@
testing.expectEqual(true, style.disabled); testing.expectEqual(true, style.disabled);
} }
</script> </script>
<script id="style-load-event">
{
// A style element fires a load event when appended to the DOM.
const style = document.createElement("style");
let result = false;
testing.async(async () => {
await new Promise(resolve => {
style.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(style, target);
result = true;
return resolve();
});
document.head.appendChild(style);
});
});
testing.eventually(() => testing.expectEqual(true, result));
}
</script>

View File

@@ -45,7 +45,6 @@
testing.expectEqual('hi', $('#link').innerText); testing.expectEqual('hi', $('#link').innerText);
d1.innerHTML = ''; d1.innerHTML = '';
testing.todo(null, $('#link'));
</script> </script>
<script id=attributeSerialization> <script id=attributeSerialization>

View File

@@ -103,3 +103,16 @@
document.dispatchEvent(new KeyboardEvent('keytest', {key: 'b'})); document.dispatchEvent(new KeyboardEvent('keytest', {key: 'b'}));
testing.expectEqual(false, keyIsTrusted); testing.expectEqual(false, keyIsTrusted);
</script> </script>
<script id=non_keyboard_keydown>
// this used to crash
{
let called = false;
const div = document.createElement('div')
div.addEventListener('keydown', () => {
called = true;
});
div.dispatchEvent(new Event('keydown'));
testing.expectEqual(true, called);
}
</script>

View File

@@ -0,0 +1,197 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<script src="./testing.js"></script>
<script id=basic>
{
const reader = new FileReader();
testing.expectEqual(0, reader.readyState);
testing.expectEqual(null, reader.result);
testing.expectEqual(null, reader.error);
}
// Constants
testing.expectEqual(0, FileReader.EMPTY);
testing.expectEqual(1, FileReader.LOADING);
testing.expectEqual(2, FileReader.DONE);
</script>
<script id=readAsText>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["Hello, World!"], { type: "text/plain" });
let loadstartFired = false;
let progressFired = false;
let loadFired = false;
let loadendFired = false;
const promise = new Promise((resolve) => {
reader.onloadstart = function(e) {
loadstartFired = true;
testing.expectEqual("loadstart", e.type);
testing.expectEqual(1, reader.readyState);
};
reader.onprogress = function(e) {
progressFired = true;
testing.expectEqual(13, e.loaded);
testing.expectEqual(13, e.total);
};
reader.onload = function(e) {
loadFired = true;
testing.expectEqual(2, reader.readyState);
testing.expectEqual("Hello, World!", reader.result);
};
reader.onloadend = function(e) {
loadendFired = true;
testing.expectEqual(true, loadstartFired);
testing.expectEqual(true, progressFired);
testing.expectEqual(true, loadFired);
resolve();
};
});
reader.readAsText(blob);
await promise;
});
</script>
<script id=readAsDataURL>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["test"], { type: "text/plain" });
const promise = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("data:text/plain;base64,dGVzdA==", reader.result);
resolve();
};
});
reader.readAsDataURL(blob);
await promise;
});
// Empty MIME type
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["test"]);
const promise = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("data:application/octet-stream;base64,dGVzdA==", reader.result);
resolve();
};
});
reader.readAsDataURL(blob);
await promise;
});
</script>
<script id=readAsArrayBuffer>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob([new Uint8Array([65, 66, 67])]);
const promise = new Promise((resolve) => {
reader.onload = function() {
const result = reader.result;
testing.expectEqual(true, result instanceof ArrayBuffer);
testing.expectEqual(3, result.byteLength);
const view = new Uint8Array(result);
testing.expectEqual(65, view[0]);
testing.expectEqual(66, view[1]);
testing.expectEqual(67, view[2]);
resolve();
};
});
reader.readAsArrayBuffer(blob);
await promise;
});
</script>
<script id=readAsBinaryString>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["ABC"]);
const promise = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("ABC", reader.result);
resolve();
};
});
reader.readAsBinaryString(blob);
await promise;
});
</script>
<script id=abort>
// Test aborting when not loading (should do nothing)
{
const reader = new FileReader();
reader.abort(); // Should not throw
testing.expectEqual(0, reader.readyState);
}
// Note: Testing abort during read is implementation-dependent.
// In synchronous implementations (like ours), the read completes before abort can be called.
// In async implementations (like Firefox), you can abort during the read.
// We test that abort() at least doesn't throw and maintains correct state.
</script>
<script id=multipleReads>
testing.async(async () => {
const reader = new FileReader();
const blob1 = new Blob(["first"]);
const blob2 = new Blob(["second"]);
// First read
const promise1 = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("first", reader.result);
resolve();
};
});
reader.readAsText(blob1);
await promise1;
// Second read - should work after first completes
const promise2 = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("second", reader.result);
resolve();
};
});
reader.readAsText(blob2);
await promise2;
});
</script>
<script id=addEventListener>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["test"]);
let loadFired = false;
const promise = new Promise((resolve) => {
reader.addEventListener("load", function() {
loadFired = true;
resolve();
});
});
reader.readAsText(blob);
await promise;
testing.expectEqual(true, loadFired);
});
</script>

View File

@@ -7,7 +7,7 @@
} }
</script> </script>
<iframe id=f1 onload="frame1Onload" src="support/sub1.html"></iframe> <iframe id=f1 onload="frame1Onload" src="support/sub 1.html"></iframe>
<iframe id=f2 src="support/sub2.html"></iframe> <iframe id=f2 src="support/sub2.html"></iframe>
<script id="basic"> <script id="basic">
@@ -25,6 +25,7 @@
testing.expectEqual(0, $('#f1').childNodes.length); testing.expectEqual(0, $('#f1').childNodes.length);
testing.expectEqual(testing.BASE_URL + 'frames/support/sub%201.html', $('#f1').src);
testing.expectEqual(window[0], $('#f1').contentWindow); testing.expectEqual(window[0], $('#f1').contentWindow);
testing.expectEqual(window[1], $('#f2').contentWindow); testing.expectEqual(window[1], $('#f2').contentWindow);

View File

@@ -19,3 +19,13 @@
</script> </script>
<script id=datauri src="data:text/plain;charset=utf-8;base64,dGVzdGluZy5leHBlY3RFcXVhbCh0cnVlLCB0cnVlKTs="></script> <script id=datauri src="data:text/plain;charset=utf-8;base64,dGVzdGluZy5leHBlY3RFcXVhbCh0cnVlLCB0cnVlKTs="></script>
<script id=datauri_url_encoded_text src="data:text/javascript,testing.expectEqual(3, 3);"></script>
<script id=datauri_encoded_padding src="data:text/javascript;base64,dGVzdGluZy5leHBlY3RFcXVhbCgxLCAxKTs%3D"></script>
<script id=datauri_fully_encoded src="data:text/javascript;base64,%64%47%56%7a%64%47%6c%75%5a%79%35%6c%65%48%42%6c%59%33%52%46%63%58%56%68%62%43%67%79%4c%43%41%79%4b%54%73%3d"></script>
<script id=datauri_with_whitespace src="data:text/javascript;base64,%20ZD%20Qg%0D%0APS%20An%20Zm91cic%0D%0A%207%20"></script>
<script id=datauri_url_encoded_unicode src="data:text/javascript,testing.expectEqual(4%2C%204)%3B"></script>

View File

@@ -180,6 +180,13 @@
testing.expectEqual(true, response.body !== null); testing.expectEqual(true, response.body !== null);
testing.expectEqual(true, response.body instanceof ReadableStream); testing.expectEqual(true, response.body instanceof ReadableStream);
const buf = await response.arrayBuffer()
restore();
const uint8array = new Uint8Array(buf);
const decoder = new TextDecoder('utf-8');
testing.expectEqual('1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890', decoder.decode(uint8array));
}); });
</script> </script>

View File

@@ -111,7 +111,7 @@
<script id=legacy> <script id=legacy>
{ {
let request = new Request("flower.png"); let request = new Request("flower.png");
testing.expectEqual("http://127.0.0.1:9582/src/browser/tests/net/flower.png", request.url); testing.expectEqual(testing.BASE_URL + 'net/flower.png', request.url);
testing.expectEqual("GET", request.method); testing.expectEqual("GET", request.method);
let request2 = new Request("https://google.com", { let request2 = new Request("https://google.com", {

View File

@@ -5,7 +5,7 @@
<a href="foo" id="foo">foo</a> <a href="foo" id="foo">foo</a>
<script id=baseURI> <script id=baseURI>
testing.expectEqual("http://127.0.0.1:9582/src/browser/tests/node/base_uri.html", document.URL); testing.expectEqual(testing.BASE_URL + 'node/base_uri.html', document.URL);
testing.expectEqual("https://example.com/", document.baseURI); testing.expectEqual("https://example.com/", document.baseURI);
const link = $('#foo'); const link = $('#foo');

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<!-- When scripting is enabled the HTML parser treats <noscript> as a raw text
element: its entire content is stored as a single text node containing the
raw markup. Serializing it back (outerHTML / innerHTML) must output that
markup as-is without HTML-escaping the angle brackets. -->
<noscript id="ns1"><h1>Hello</h1><p>World</p></noscript>
<noscript id="ns2"><div id="bsky_post_summary"><h3>Post</h3><p id="bsky_display_name">Henri Helvetica</p></div></noscript>
<script id="noscript-outerHTML">
const ns1 = document.getElementById('ns1');
testing.expectEqual('<noscript id="ns1"><h1>Hello</h1><p>World</p></noscript>', ns1.outerHTML);
</script>
<script id="noscript-innerHTML">
const ns2 = document.getElementById('ns2');
testing.expectEqual('<div id="bsky_post_summary"><h3>Post</h3><p id="bsky_display_name">Henri Helvetica</p></div>', ns2.innerHTML);
</script>
<script id="noscript-textContent">
// The raw text node content must be the literal HTML markup (not parsed DOM)
testing.expectEqual('<h1>Hello</h1><p>World</p>', ns1.firstChild.nodeValue);
</script>

View File

@@ -252,6 +252,72 @@
} }
</script> </script>
<script id=measure_with_navigation_timing_marks>
{
// performance.measure() must accept PerformanceTiming attribute names
// (e.g. "fetchStart") as start/end marks per the W3C User Timing Level 2 spec.
// https://www.w3.org/TR/user-timing/#dom-performance-measure
performance.clearMarks();
performance.clearMeasures();
performance.mark("mark-dataComplete");
let m = performance.measure("dataComplete", "fetchStart", "mark-dataComplete");
testing.expectEqual('dataComplete', m.name);
testing.expectEqual('measure', m.entryType);
testing.expectEqual(true, m.duration >= 0);
// navigationStart is also a valid mark name (was already supported)
performance.mark("mark-end");
let m2 = performance.measure("fromNav", "navigationStart", "mark-end");
testing.expectEqual('fromNav', m2.name);
testing.expectEqual(true, m2.duration >= 0);
}
</script>
<script id=performance_timing_exists>
{
// performance.timing must not be undefined (used by sites like Bing)
testing.expectEqual(true, performance.timing !== undefined);
testing.expectEqual(true, performance.timing !== null);
}
</script>
<script id=performance_timing_navigationStart>
{
// navigationStart must be a number (sites access performance.timing.navigationStart)
const timing = performance.timing;
testing.expectEqual('number', typeof timing.navigationStart);
testing.expectEqual(0, timing.navigationStart);
}
</script>
<script id=performance_timing_all_properties>
{
// All PerformanceTiming properties must be accessible and return numbers
const timing = performance.timing;
const props = [
'navigationStart', 'unloadEventStart', 'unloadEventEnd',
'redirectStart', 'redirectEnd', 'fetchStart',
'domainLookupStart', 'domainLookupEnd',
'connectStart', 'connectEnd', 'secureConnectionStart',
'requestStart', 'responseStart', 'responseEnd',
'domLoading', 'domInteractive',
'domContentLoadedEventStart', 'domContentLoadedEventEnd',
'domComplete', 'loadEventStart', 'loadEventEnd',
];
for (const prop of props) {
testing.expectEqual('number', typeof timing[prop]);
}
}
</script>
<script id=performance_timing_same_object>
{
// performance.timing should return the same object on each access
testing.expectEqual(true, performance.timing === performance.timing);
}
</script>
<script id=mixed_marks_and_measures> <script id=mixed_marks_and_measures>
{ {
performance.clearMarks(); performance.clearMarks();
@@ -280,3 +346,50 @@
testing.expectEqual(0, remainingMarks.length); testing.expectEqual(0, remainingMarks.length);
} }
</script> </script>
<script id=performance_timing_exists>
{
// Navigation Timing Level 1: performance.timing must be an object, not undefined
testing.expectEqual('object', typeof performance.timing);
testing.expectEqual(false, performance.timing === null);
}
</script>
<script id=performance_timing_navigationStart>
{
// The most commonly used property — must be a number (not undefined)
testing.expectEqual('number', typeof performance.timing.navigationStart);
testing.expectEqual(0, performance.timing.navigationStart);
}
</script>
<script id=performance_navigation_exists>
{
// Navigation Timing Level 1: performance.navigation must be an object, not undefined
testing.expectEqual('object', typeof performance.navigation);
testing.expectEqual(false, performance.navigation === null);
testing.expectEqual('number', typeof performance.navigation.type);
testing.expectEqual('number', typeof performance.navigation.redirectCount);
testing.expectEqual(0, performance.navigation.type);
testing.expectEqual(0, performance.navigation.redirectCount);
}
</script>
<script id=performance_timing_all_properties>
{
const t = performance.timing;
const props = [
'navigationStart', 'unloadEventStart', 'unloadEventEnd',
'redirectStart', 'redirectEnd', 'fetchStart',
'domainLookupStart', 'domainLookupEnd',
'connectStart', 'connectEnd', 'secureConnectionStart',
'requestStart', 'responseStart', 'responseEnd',
'domLoading', 'domInteractive',
'domContentLoadedEventStart', 'domContentLoadedEventEnd',
'domComplete', 'loadEventStart', 'loadEventEnd',
];
for (const prop of props) {
testing.expectEqual('number', typeof t[prop]);
}
}
</script>

View File

@@ -546,14 +546,14 @@
{ {
const sel = window.getSelection(); const sel = window.getSelection();
sel.removeAllRanges(); sel.removeAllRanges();
let eventCount = 0; let eventCount = 0;
let lastEvent = null; let lastEvent = null;
document.addEventListener('selectionchange', (e) => { const listener = (e) => {
eventCount++; eventCount++;
lastEvent = e; lastEvent = e;
}); };
document.addEventListener('selectionchange', listener);
const p1 = document.getElementById("p1"); const p1 = document.getElementById("p1");
const textNode = p1.firstChild; const textNode = p1.firstChild;
@@ -563,27 +563,25 @@
sel.extend(textNode, 10); sel.extend(textNode, 10);
sel.collapseToStart(); sel.collapseToStart();
sel.collapseToEnd(); sel.collapseToEnd();
sel.removeAllRanges(); sel.removeAllRanges();
const range = document.createRange(); const range = document.createRange();
range.setStart(textNode, 4); range.setStart(textNode, 4);
range.setEnd(textNode, 15); range.setEnd(textNode, 15);
sel.addRange(range); sel.addRange(range);
sel.removeRange(range); sel.removeRange(range);
const newRange = document.createRange(); const newRange = document.createRange();
newRange.selectNodeContents(p1); newRange.selectNodeContents(p1);
sel.addRange(newRange); sel.addRange(newRange);
sel.removeAllRanges(); sel.removeAllRanges();
sel.selectAllChildren(nested); sel.selectAllChildren(nested);
sel.setBaseAndExtent(textNode, 4, textNode, 15); sel.setBaseAndExtent(textNode, 4, textNode, 15);
sel.collapse(textNode, 5); sel.collapse(textNode, 5);
sel.extend(textNode, 10); sel.extend(textNode, 10);
sel.deleteFromDocument(); sel.deleteFromDocument();
document.removeEventListener('selectionchange', listener);
textNode.textContent = "The quick brown fox";
testing.eventually(() => { testing.eventually(() => {
testing.expectEqual(14, eventCount); testing.expectEqual(14, eventCount);
testing.expectEqual('selectionchange', lastEvent.type); testing.expectEqual('selectionchange', lastEvent.type);
@@ -593,3 +591,149 @@
}); });
} }
</script> </script>
<script id=modifyCharacterForward>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild; // "The quick brown fox"
// Collapse to position 4 (after "The ")
sel.collapse(textNode, 4);
testing.expectEqual(4, sel.anchorOffset);
// Move forward one character
sel.modify("move", "forward", "character");
testing.expectEqual(5, sel.anchorOffset);
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual("none", sel.direction);
// Move forward again
sel.modify("move", "forward", "character");
testing.expectEqual(6, sel.anchorOffset);
}
</script>
<script id=modifyWordForward>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild; // "The quick brown fox"
// Collapse to start
sel.collapse(textNode, 0);
// Move forward one word - should land at end of "The"
sel.modify("move", "forward", "word");
testing.expectEqual(3, sel.anchorOffset);
testing.expectEqual(true, sel.isCollapsed);
// Move forward again - should skip space and land at end of "quick"
sel.modify("move", "forward", "word");
testing.expectEqual(9, sel.anchorOffset);
}
</script>
<script id=modifyCharacterBackward>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild; // "The quick brown fox"
// Collapse to position 6
sel.collapse(textNode, 6);
testing.expectEqual(6, sel.anchorOffset);
// Move backward one character
sel.modify("move", "backward", "character");
testing.expectEqual(5, sel.anchorOffset);
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual("none", sel.direction);
// Move backward again
sel.modify("move", "backward", "character");
testing.expectEqual(4, sel.anchorOffset);
}
</script>
<script id=modifyWordBackward>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild; // "The quick brown fox"
// Collapse to end of "quick" (offset 9)
sel.collapse(textNode, 9);
// Move backward one word - should land at start of "quick"
sel.modify("move", "backward", "word");
testing.expectEqual(4, sel.anchorOffset);
testing.expectEqual(true, sel.isCollapsed);
// Move backward again - should land at start of "The"
sel.modify("move", "backward", "word");
testing.expectEqual(0, sel.anchorOffset);
}
</script>
<script id=modifyCharacterForwardFromElementNode>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
sel.collapse(p1, 1);
testing.expectEqual(p1, sel.anchorNode);
testing.expectEqual(1, sel.anchorOffset);
testing.expectEqual(true, sel.isCollapsed);
sel.modify("move", "forward", "character");
testing.expectEqual(3, sel.anchorNode.nodeType);
testing.expectEqual(true, sel.anchorNode !== p1.firstChild);
}
</script>
<script id=modifyCharacterForwardFromElementNodeMidChildren>
{
const sel = window.getSelection();
const nested = document.getElementById("nested");
sel.collapse(nested, nested.childNodes.length);
testing.expectEqual(nested, sel.anchorNode);
testing.expectEqual(nested.childNodes.length, sel.anchorOffset);
sel.modify("move", "forward", "character");
// Must land on a text node strictly after #nested
testing.expectEqual(3, sel.anchorNode.nodeType);
testing.expectEqual(false, nested.contains(sel.anchorNode));
}
</script>
<script id=modifyWordForwardFromElementNode>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
sel.collapse(p1, 1);
sel.modify("move", "forward", "word");
// Must land on a text node strictly after p1
testing.expectEqual(3, sel.anchorNode.nodeType);
testing.expectEqual(false, p1.contains(sel.anchorNode));
testing.expectEqual(true, sel.isCollapsed);
}
</script>
<script id=modifyCharacterForwardNewNodeOffsetNotElement>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
sel.collapse(p1, 1);
sel.modify("move", "forward", "character");
testing.expectEqual(3, sel.anchorNode.nodeType);
}
</script>

View File

@@ -88,3 +88,12 @@
localStorage.clear(); localStorage.clear();
testing.expectEqual(0, localStorage.length) testing.expectEqual(0, localStorage.length)
</script> </script>
<script id="localstorage_limits">
localStorage.clear();
for (i = 0; i < 5; i++) {
const v = "v".repeat(1024 * 1024);
localStorage.setItem(v, v);
}
testing.expectError("QuotaExceededError", () => localStorage.setItem("last", "v"));
</script>

View File

@@ -99,6 +99,9 @@
} }
} }
// our test runner sets this to true
const IS_TEST_RUNNER = window._lightpanda_skip_auto_assert === true;
window.testing = { window.testing = {
fail: fail, fail: fail,
async: async, async: async,
@@ -109,9 +112,24 @@
expectError: expectError, expectError: expectError,
withError: withError, withError: withError,
eventually: eventually, eventually: eventually,
todo: function(){}, IS_TEST_RUNNER: IS_TEST_RUNNER,
HOST: '127.0.0.1',
ORIGIN: 'http://127.0.0.1:9582/',
BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/',
}; };
if (!IS_TEST_RUNNER) {
// The page is running in a different browser. Probably a developer making sure
// a test is correct. There are a few tweaks we need to do to make this a
// seemless, namely around adapting paths/urls.
console.warn(`The page is not being executed in the test runner, certain behavior has been adjusted`);
window.testing.HOST = location.hostname;
window.testing.ORIGIN = location.origin + '/';
window.testing.BASE_URL = location.origin + '/src/browser/tests/';
window.addEventListener('load', testing.assertOk);
}
window.$ = function(sel) { window.$ = function(sel) {
return document.querySelector(sel); return document.querySelector(sel);
} }
@@ -220,8 +238,4 @@
return val; return val;
}); });
} }
if (window._lightpanda_skip_auto_assert !== true) {
window.addEventListener('load', testing.assertOk);
}
})(); })();

View File

@@ -2,24 +2,24 @@
<script src="../testing.js"></script> <script src="../testing.js"></script>
<script id=location> <script id=location>
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/window/location.html', window.location.href); testing.expectEqual(testing.BASE_URL + 'window/location.html', window.location.href);
testing.expectEqual(document.location, window.location); testing.expectEqual(document.location, window.location);
</script> </script>
<script id=location_hash> <script id=location_hash>
location.hash = ""; location.hash = "";
testing.expectEqual("", location.hash); testing.expectEqual("", location.hash);
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/window/location.html', location.href); testing.expectEqual(testing.BASE_URL + 'window/location.html', location.href);
location.hash = "#abcdef"; location.hash = "#abcdef";
testing.expectEqual("#abcdef", location.hash); testing.expectEqual("#abcdef", location.hash);
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/window/location.html#abcdef', location.href); testing.expectEqual(testing.BASE_URL + 'window/location.html#abcdef', location.href);
location.hash = "xyzxyz"; location.hash = "xyzxyz";
testing.expectEqual("#xyzxyz", location.hash); testing.expectEqual("#xyzxyz", location.hash);
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/window/location.html#xyzxyz', location.href); testing.expectEqual(testing.BASE_URL + 'window/location.html#xyzxyz', location.href);
location.hash = ""; location.hash = "";
testing.expectEqual("", location.hash); testing.expectEqual("", location.hash);
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/window/location.html', location.href); testing.expectEqual(testing.BASE_URL + 'window/location.html', location.href);
</script> </script>

View File

@@ -3,8 +3,10 @@
<script id=screen> <script id=screen>
let screen = window.screen; let screen = window.screen;
testing.expectEqual(1920, screen.width); if (testing.IS_TEST_RUNNER) {
testing.expectEqual(1080, screen.height); testing.expectEqual(1920, screen.width);
testing.expectEqual(1080, screen.height);
}
let orientation = screen.orientation; let orientation = screen.orientation;
testing.expectEqual(0, orientation.angle); testing.expectEqual(0, orientation.angle);

View File

@@ -23,7 +23,6 @@
}); });
</script> </script>
<script id=setTimeout> <script id=setTimeout>
testing.expectEqual(1, window.setTimeout.length); testing.expectEqual(1, window.setTimeout.length);
let wst2 = 1; let wst2 = 1;
@@ -32,3 +31,11 @@
}, 1, 2, 3); }, 1, 2, 3);
testing.eventually(() => testing.expectEqual(5, wst2)); testing.eventually(() => testing.expectEqual(5, wst2));
</script> </script>
<script id=invalid-timer-clear>
// Surprisingly, these don't fail but silently ignored.
clearTimeout(-1);
clearInterval(-2);
clearImmediate(-3);
testing.expectEqual(true, true);
</script>

View File

@@ -75,6 +75,16 @@
testing.expectEqual('abc', atob('YWJj')); testing.expectEqual('abc', atob('YWJj'));
testing.expectEqual('0123456789', atob('MDEyMzQ1Njc4OQ==')); testing.expectEqual('0123456789', atob('MDEyMzQ1Njc4OQ=='));
testing.expectEqual('The quick brown fox jumps over the lazy dog', atob('VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw==')); testing.expectEqual('The quick brown fox jumps over the lazy dog', atob('VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw=='));
// atob must accept unpadded base64 (forgiving-base64 decode per HTML spec)
testing.expectEqual('a', atob('YQ')); // 2 chars, len%4==2, needs '=='
testing.expectEqual('ab', atob('YWI')); // 3 chars, len%4==3, needs '='
testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '=='
// length % 4 == 1 must still throw
testing.expectError('Error: InvalidCharacterError', () => {
atob('Y');
});
</script> </script>
<script id=btoa_atob_roundtrip> <script id=btoa_atob_roundtrip>

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<script id=scrollBy_exists>
testing.expectEqual('function', typeof window.scrollBy);
</script>
<script id=scrollBy_xy>
window.scrollTo(0, 0);
testing.expectEqual(0, window.scrollX);
testing.expectEqual(0, window.scrollY);
window.scrollBy(100, 200);
testing.expectEqual(100, window.scrollX);
testing.expectEqual(200, window.scrollY);
</script>
<script id=scrollBy_relative>
window.scrollTo(100, 100);
window.scrollBy(50, 50);
testing.expectEqual(150, window.scrollX);
testing.expectEqual(150, window.scrollY);
</script>
<script id=scrollBy_opts>
window.scrollTo(0, 0);
window.scrollBy({ left: 30, top: 40 });
testing.expectEqual(30, window.scrollX);
testing.expectEqual(40, window.scrollY);
</script>
<script id=scrollBy_negative_clamp>
window.scrollTo(10, 10);
window.scrollBy(-100, -100);
testing.expectEqual(0, window.scrollX);
testing.expectEqual(0, window.scrollY);
</script>

View File

@@ -129,3 +129,14 @@
testing.expectEqual(original, serialized); testing.expectEqual(original, serialized);
} }
</script> </script>
<script id=serializeAttribute>
{
const div = document.createElement('div');
div.setAttribute('over', '9000');
const serializer = new XMLSerializer();
const serialized = serializer.serializeToString(div.getAttributeNode('over'));
testing.expectEqual('', serialized);
}
</script>

View File

@@ -77,8 +77,6 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, local: *const js.Local, page:
// Dispatch abort event // Dispatch abort event
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page); const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page);
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatchWithFunction( try page._event_manager.dispatchWithFunction(
self.asEventTarget(), self.asEventTarget(),
event, event,

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const String = @import("../../string.zig").String;
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
@@ -31,7 +32,7 @@ const CData = @This();
_type: Type, _type: Type,
_proto: *Node, _proto: *Node,
_data: []const u8 = "", _data: String = .empty,
/// Count UTF-16 code units in a UTF-8 string. /// Count UTF-16 code units in a UTF-8 string.
/// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair), /// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair),
@@ -157,7 +158,7 @@ pub fn is(self: *CData, comptime T: type) ?*T {
return null; return null;
} }
pub fn getData(self: *const CData) []const u8 { pub fn getData(self: *const CData) String {
return self._data; return self._data;
} }
@@ -172,7 +173,7 @@ pub fn render(self: *const CData, writer: *std.io.Writer, opts: RenderOpts) !boo
var start: usize = 0; var start: usize = 0;
var prev_w: ?bool = null; var prev_w: ?bool = null;
var is_w: bool = undefined; var is_w: bool = undefined;
const s = self._data; const s = self._data.str();
for (s, 0..) |c, i| { for (s, 0..) |c, i| {
is_w = std.ascii.isWhitespace(c); is_w = std.ascii.isWhitespace(c);
@@ -222,9 +223,9 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void {
const old_value = self._data; const old_value = self._data;
if (value) |v| { if (value) |v| {
self._data = try page.dupeString(v); self._data = try page.dupeSSO(v);
} else { } else {
self._data = ""; self._data = .empty;
} }
page.characterDataChange(self.asNode(), old_value); page.characterDataChange(self.asNode(), old_value);
@@ -243,15 +244,15 @@ pub fn _setData(self: *CData, value: js.Value, page: *Page) !void {
pub fn format(self: *const CData, writer: *std.io.Writer) !void { pub fn format(self: *const CData, writer: *std.io.Writer) !void {
return switch (self._type) { return switch (self._type) {
.text => writer.print("<text>{s}</text>", .{self._data}), .text => writer.print("<text>{f}</text>", .{self._data}),
.comment => writer.print("<!-- {s} -->", .{self._data}), .comment => writer.print("<!-- {f} -->", .{self._data}),
.cdata_section => writer.print("<![CDATA[{s}]]>", .{self._data}), .cdata_section => writer.print("<![CDATA[{f}]]>", .{self._data}),
.processing_instruction => |pi| writer.print("<?{s} {s}?>", .{ pi._target, self._data }), .processing_instruction => |pi| writer.print("<?{s} {f}?>", .{ pi._target, self._data }),
}; };
} }
pub fn getLength(self: *const CData) usize { pub fn getLength(self: *const CData) usize {
return utf16Len(self._data); return utf16Len(self._data.str());
} }
pub fn isEqualNode(self: *const CData, other: *const CData) bool { pub fn isEqualNode(self: *const CData, other: *const CData) bool {
@@ -267,58 +268,64 @@ pub fn isEqualNode(self: *const CData, other: *const CData) bool {
// if the _targets are equal, we still want to compare the data // if the _targets are equal, we still want to compare the data
} }
return std.mem.eql(u8, self.getData(), other.getData()); return self._data.eql(other._data);
} }
pub fn appendData(self: *CData, data: []const u8, page: *Page) !void { pub fn appendData(self: *CData, data: []const u8, page: *Page) !void {
const new_data = try std.mem.concat(page.arena, u8, &.{ self._data, data }); const old_value = self._data;
try self.setData(new_data, page); self._data = try String.concat(page.arena, &.{ self._data.str(), data });
page.characterDataChange(self.asNode(), old_value);
} }
pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void { pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void {
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
const range = try utf16RangeToUtf8(self._data, offset, end_utf16); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
// Just slice - original data stays in arena const old_data = self._data;
const old_value = self._data; const old_value = old_data.str();
if (range.start == 0) { if (range.start == 0) {
self._data = self._data[range.end..]; self._data = try page.dupeSSO(old_value[range.end..]);
} else if (range.end >= self._data.len) { } else if (range.end >= old_value.len) {
self._data = self._data[0..range.start]; self._data = try page.dupeSSO(old_value[0..range.start]);
} else { } else {
self._data = try std.mem.concat(page.arena, u8, &.{ // Deleting from middle - concat prefix and suffix
self._data[0..range.start], self._data = try String.concat(page.arena, &.{
self._data[range.end..], old_value[0..range.start],
old_value[range.end..],
}); });
} }
page.characterDataChange(self.asNode(), old_value); page.characterDataChange(self.asNode(), old_data);
} }
pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void { pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void {
const byte_offset = try utf16OffsetToUtf8(self._data, offset); const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset);
const new_data = try std.mem.concat(page.arena, u8, &.{ const old_value = self._data;
self._data[0..byte_offset], const existing = old_value.str();
self._data = try String.concat(page.arena, &.{
existing[0..byte_offset],
data, data,
self._data[byte_offset..], existing[byte_offset..],
}); });
try self.setData(new_data, page); page.characterDataChange(self.asNode(), old_value);
} }
pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void { pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void {
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
const range = try utf16RangeToUtf8(self._data, offset, end_utf16); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
const new_data = try std.mem.concat(page.arena, u8, &.{ const old_value = self._data;
self._data[0..range.start], const existing = old_value.str();
self._data = try String.concat(page.arena, &.{
existing[0..range.start],
data, data,
self._data[range.end..], existing[range.end..],
}); });
try self.setData(new_data, page); page.characterDataChange(self.asNode(), old_value);
} }
pub fn substringData(self: *const CData, offset: usize, count: usize) ![]const u8 { pub fn substringData(self: *const CData, offset: usize, count: usize) ![]const u8 {
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
const range = try utf16RangeToUtf8(self._data, offset, end_utf16); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
return self._data[range.start..range.end]; return self._data.str()[range.start..range.end];
} }
pub fn remove(self: *CData, page: *Page) !void { pub fn remove(self: *CData, page: *Page) !void {
@@ -451,7 +458,7 @@ test "WebApi: CData.render" {
const cdata = CData{ const cdata = CData{
._type = .{ .text = undefined }, ._type = .{ .text = undefined },
._proto = undefined, ._proto = undefined,
._data = test_case.value, ._data = .wrap(test_case.value),
}; };
const result = try cdata.render(&buffer.writer, test_case.opts); const result = try cdata.render(&buffer.writer, test_case.opts);

View File

@@ -178,7 +178,7 @@ pub const JsApi = struct {
pub const info = bridge.function(Console.info, .{}); pub const info = bridge.function(Console.info, .{});
pub const log = bridge.function(Console.log, .{}); pub const log = bridge.function(Console.log, .{});
pub const warn = bridge.function(Console.warn, .{}); pub const warn = bridge.function(Console.warn, .{});
pub const clear = bridge.function(Console.clear, .{}); pub const clear = bridge.function(Console.clear, .{ .noop = true });
pub const assert = bridge.function(Console.assert, .{}); pub const assert = bridge.function(Console.assert, .{});
pub const @"error" = bridge.function(Console.@"error", .{}); pub const @"error" = bridge.function(Console.@"error", .{});
pub const exception = bridge.function(Console.@"error", .{}); pub const exception = bridge.function(Console.@"error", .{});

View File

@@ -32,7 +32,7 @@ pub fn getRandomValues(_: *const Crypto, js_obj: js.Object) !js.Object {
var into = try js_obj.toZig(RandomValues); var into = try js_obj.toZig(RandomValues);
const buf = into.asBuffer(); const buf = into.asBuffer();
if (buf.len > 65_536) { if (buf.len > 65_536) {
return error.QuotaExceededError; return error.QuotaExceeded;
} }
std.crypto.random.bytes(buf); std.crypto.random.bytes(buf);
return js_obj; return js_obj;
@@ -82,7 +82,7 @@ pub const JsApi = struct {
pub const empty_with_no_proto = true; pub const empty_with_no_proto = true;
}; };
pub const getRandomValues = bridge.function(Crypto.getRandomValues, .{}); pub const getRandomValues = bridge.function(Crypto.getRandomValues, .{ .dom_exception = true });
pub const randomUUID = bridge.function(Crypto.randomUUID, .{}); pub const randomUUID = bridge.function(Crypto.randomUUID, .{});
pub const subtle = bridge.accessor(Crypto.getSubtle, null, .{}); pub const subtle = bridge.accessor(Crypto.getSubtle, null, .{});
}; };

View File

@@ -24,6 +24,7 @@ const Page = @import("../Page.zig");
const Node = @import("Node.zig"); const Node = @import("Node.zig");
const Element = @import("Element.zig"); const Element = @import("Element.zig");
const DOMException = @import("DOMException.zig");
const Custom = @import("element/html/Custom.zig"); const Custom = @import("element/html/Custom.zig");
const CustomElementDefinition = @import("CustomElementDefinition.zig"); const CustomElementDefinition = @import("CustomElementDefinition.zig");
@@ -124,6 +125,10 @@ pub fn whenDefined(self: *CustomElementRegistry, name: []const u8, page: *Page)
return local.resolvePromise(definition.constructor); return local.resolvePromise(definition.constructor);
} }
validateName(name) catch |err| {
return local.rejectPromise(DOMException.fromError(err) orelse unreachable);
};
const gop = try self._when_defined.getOrPut(page.arena, name); const gop = try self._when_defined.getOrPut(page.arena, name);
if (gop.found_existing) { if (gop.found_existing) {
return local.toLocal(gop.value_ptr.*).promise(); return local.toLocal(gop.value_ptr.*).promise();
@@ -200,15 +205,15 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio
fn validateName(name: []const u8) !void { fn validateName(name: []const u8) !void {
if (name.len == 0) { if (name.len == 0) {
return error.InvalidCustomElementName; return error.SyntaxError;
} }
if (std.mem.indexOf(u8, name, "-") == null) { if (std.mem.indexOf(u8, name, "-") == null) {
return error.InvalidCustomElementName; return error.SyntaxError;
} }
if (name[0] < 'a' or name[0] > 'z') { if (name[0] < 'a' or name[0] > 'z') {
return error.InvalidCustomElementName; return error.SyntaxError;
} }
const reserved_names = [_][]const u8{ const reserved_names = [_][]const u8{
@@ -224,16 +229,20 @@ fn validateName(name: []const u8) !void {
for (reserved_names) |reserved| { for (reserved_names) |reserved| {
if (std.mem.eql(u8, name, reserved)) { if (std.mem.eql(u8, name, reserved)) {
return error.InvalidCustomElementName; return error.SyntaxError;
} }
} }
for (name) |c| { for (name) |c| {
const valid = (c >= 'a' and c <= 'z') or if (c >= 'A' and c <= 'Z') {
(c >= '0' and c <= '9') or return error.SyntaxError;
c == '-'; }
if (!valid) {
return error.InvalidCustomElementName; // Reject control characters and specific invalid characters
// per elementLocalNameRegex: [^\0\t\n\f\r\u0020/>]*
switch (c) {
0, '\t', '\n', '\r', 0x0C, ' ', '/', '>' => return error.SyntaxError,
else => {},
} }
} }
} }
@@ -250,7 +259,7 @@ pub const JsApi = struct {
pub const define = bridge.function(CustomElementRegistry.define, .{ .dom_exception = true }); pub const define = bridge.function(CustomElementRegistry.define, .{ .dom_exception = true });
pub const get = bridge.function(CustomElementRegistry.get, .{ .null_as_undefined = true }); pub const get = bridge.function(CustomElementRegistry.get, .{ .null_as_undefined = true });
pub const upgrade = bridge.function(CustomElementRegistry.upgrade, .{}); pub const upgrade = bridge.function(CustomElementRegistry.upgrade, .{});
pub const whenDefined = bridge.function(CustomElementRegistry.whenDefined, .{}); pub const whenDefined = bridge.function(CustomElementRegistry.whenDefined, .{ .dom_exception = true });
}; };
const testing = @import("../../testing.zig"); const testing = @import("../../testing.zig");

View File

@@ -29,6 +29,9 @@ const Document = @import("Document.zig");
const DOMParser = @This(); const DOMParser = @This();
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
pub fn init() DOMParser { pub fn init() DOMParser {
return .{}; return .{};
} }

View File

@@ -34,6 +34,7 @@ const DOMTreeWalker = @import("DOMTreeWalker.zig");
const DOMNodeIterator = @import("DOMNodeIterator.zig"); const DOMNodeIterator = @import("DOMNodeIterator.zig");
const DOMImplementation = @import("DOMImplementation.zig"); const DOMImplementation = @import("DOMImplementation.zig");
const StyleSheetList = @import("css/StyleSheetList.zig"); const StyleSheetList = @import("css/StyleSheetList.zig");
const FontFaceSet = @import("css/FontFaceSet.zig");
const Selection = @import("Selection.zig"); const Selection = @import("Selection.zig");
pub const XMLDocument = @import("XMLDocument.zig"); pub const XMLDocument = @import("XMLDocument.zig");
@@ -43,6 +44,7 @@ const Document = @This();
_type: Type, _type: Type,
_proto: *Node, _proto: *Node,
_page: ?*Page = null,
_location: ?*Location = null, _location: ?*Location = null,
_url: ?[:0]const u8 = null, // URL for documents created via DOMImplementation (about:blank) _url: ?[:0]const u8 = null, // URL for documents created via DOMImplementation (about:blank)
_ready_state: ReadyState = .loading, _ready_state: ReadyState = .loading,
@@ -52,6 +54,8 @@ _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty,
_removed_ids: std.StringHashMapUnmanaged(void) = .empty, _removed_ids: std.StringHashMapUnmanaged(void) = .empty,
_active_element: ?*Element = null, _active_element: ?*Element = null,
_style_sheets: ?*StyleSheetList = null, _style_sheets: ?*StyleSheetList = null,
_implementation: ?*DOMImplementation = null,
_fonts: ?*FontFaceSet = null,
_write_insertion_point: ?*Node = null, _write_insertion_point: ?*Node = null,
_script_created_parser: ?Parser.Streaming = null, _script_created_parser: ?Parser.Streaming = null,
_adopted_style_sheets: ?js.Object.Global = null, _adopted_style_sheets: ?js.Object.Global = null,
@@ -272,8 +276,11 @@ pub fn querySelectorAll(self: *Document, input: String, page: *Page) !*Selector.
return Selector.querySelectorAll(self.asNode(), input.str(), page); return Selector.querySelectorAll(self.asNode(), input.str(), page);
} }
pub fn getImplementation(_: *const Document) DOMImplementation { pub fn getImplementation(self: *Document, page: *Page) !*DOMImplementation {
return .{}; if (self._implementation) |impl| return impl;
const impl = try page._factory.create(DOMImplementation{});
self._implementation = impl;
return impl;
} }
pub fn createDocumentFragment(self: *Document, page: *Page) !*Node.DocumentFragment { pub fn createDocumentFragment(self: *Document, page: *Page) !*Node.DocumentFragment {
@@ -430,6 +437,15 @@ pub fn getStyleSheets(self: *Document, page: *Page) !*StyleSheetList {
return sheets; return sheets;
} }
pub fn getFonts(self: *Document, page: *Page) !*FontFaceSet {
if (self._fonts) |fonts| {
return fonts;
}
const fonts = try FontFaceSet.init(page);
self._fonts = fonts;
return fonts;
}
pub fn adoptNode(_: *const Document, node: *Node, page: *Page) !*Node { pub fn adoptNode(_: *const Document, node: *Node, page: *Page) !*Node {
if (node._type == .document) { if (node._type == .document) {
return error.NotSupported; return error.NotSupported;
@@ -637,11 +653,13 @@ pub fn write(self: *Document, text: []const []const u8, page: *Page) !void {
} }
if (html.len > 0) { if (html.len > 0) {
self._script_created_parser.?.read(html) catch |err| { if (self._script_created_parser) |*parser| {
log.warn(.dom, "document.write parser error", .{ .err = err }); parser.read(html) catch |err| {
// was alrady closed log.warn(.dom, "document.write parser error", .{ .err = err });
self._script_created_parser = null; // was alrady closed
}; self._script_created_parser = null;
};
}
} }
return; return;
} }
@@ -726,6 +744,7 @@ pub fn open(self: *Document, page: *Page) !*Document {
self._elements_by_id.clearAndFree(page.arena); self._elements_by_id.clearAndFree(page.arena);
self._active_element = null; self._active_element = null;
self._style_sheets = null; self._style_sheets = null;
self._implementation = null;
self._ready_state = .loading; self._ready_state = .loading;
self._script_created_parser = Parser.Streaming.init(page.arena, doc_node, page); self._script_created_parser = Parser.Streaming.init(page.arena, doc_node, page);
@@ -805,17 +824,6 @@ pub fn setAdoptedStyleSheets(self: *Document, sheets: js.Object) !void {
self._adopted_style_sheets = try sheets.persist(); self._adopted_style_sheets = try sheets.persist();
} }
pub fn getHidden(_: *const Document) bool {
// it's hidden when, for example, the decive is locked, or user is on a
// a different tab.
return false;
}
pub fn getVisibilityState(_: *const Document) []const u8 {
// See getHidden above, possible options are "visible" or "hidden"
return "visible";
}
// Validates that nodes can be inserted into a Document, respecting Document constraints: // Validates that nodes can be inserted into a Document, respecting Document constraints:
// - At most one Element child // - At most one Element child
// - At most one DocumentType child // - At most one DocumentType child
@@ -963,6 +971,7 @@ pub const JsApi = struct {
pub const implementation = bridge.accessor(Document.getImplementation, null, .{}); pub const implementation = bridge.accessor(Document.getImplementation, null, .{});
pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{}); pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{});
pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{}); pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{});
pub const fonts = bridge.accessor(Document.getFonts, null, .{});
pub const contentType = bridge.accessor(Document.getContentType, null, .{}); pub const contentType = bridge.accessor(Document.getContentType, null, .{});
pub const domain = bridge.accessor(Document.getDomain, null, .{}); pub const domain = bridge.accessor(Document.getDomain, null, .{});
pub const createElement = bridge.function(Document.createElement, .{ .dom_exception = true }); pub const createElement = bridge.function(Document.createElement, .{ .dom_exception = true });
@@ -1011,8 +1020,8 @@ pub const JsApi = struct {
pub const lastElementChild = bridge.accessor(Document.getLastElementChild, null, .{}); pub const lastElementChild = bridge.accessor(Document.getLastElementChild, null, .{});
pub const childElementCount = bridge.accessor(Document.getChildElementCount, null, .{}); pub const childElementCount = bridge.accessor(Document.getChildElementCount, null, .{});
pub const adoptedStyleSheets = bridge.accessor(Document.getAdoptedStyleSheets, Document.setAdoptedStyleSheets, .{}); pub const adoptedStyleSheets = bridge.accessor(Document.getAdoptedStyleSheets, Document.setAdoptedStyleSheets, .{});
pub const hidden = bridge.accessor(Document.getHidden, null, .{}); pub const hidden = bridge.property(false, .{ .template = false, .readonly = true });
pub const visibilityState = bridge.accessor(Document.getVisibilityState, null, .{}); pub const visibilityState = bridge.property("visible", .{ .template = false, .readonly = true });
pub const defaultView = bridge.accessor(struct { pub const defaultView = bridge.accessor(struct {
fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") { fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") {
return page.window; return page.window;

View File

@@ -868,12 +868,10 @@ pub fn focus(self: *Element, page: *Page) !void {
// Dispatch blur on old element (no bubble, composed) // Dispatch blur on old element (no bubble, composed)
const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true, .relatedTarget = new_target }, page); const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true, .relatedTarget = new_target }, page);
defer if (!blur_event.asEvent()._v8_handoff) blur_event.deinit(false);
try page._event_manager.dispatch(old_target, blur_event.asEvent()); try page._event_manager.dispatch(old_target, blur_event.asEvent());
// Dispatch focusout on old element (bubbles, composed) // Dispatch focusout on old element (bubbles, composed)
const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true, .relatedTarget = new_target }, page); const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true, .relatedTarget = new_target }, page);
defer if (!focusout_event.asEvent()._v8_handoff) focusout_event.deinit(false);
try page._event_manager.dispatch(old_target, focusout_event.asEvent()); try page._event_manager.dispatch(old_target, focusout_event.asEvent());
} }
@@ -881,12 +879,10 @@ pub fn focus(self: *Element, page: *Page) !void {
// Dispatch focus on new element (no bubble, composed) // Dispatch focus on new element (no bubble, composed)
const focus_event = try FocusEvent.initTrusted(comptime .wrap("focus"), .{ .composed = true, .relatedTarget = old_related }, page); const focus_event = try FocusEvent.initTrusted(comptime .wrap("focus"), .{ .composed = true, .relatedTarget = old_related }, page);
defer if (!focus_event.asEvent()._v8_handoff) focus_event.deinit(false);
try page._event_manager.dispatch(new_target, focus_event.asEvent()); try page._event_manager.dispatch(new_target, focus_event.asEvent());
// Dispatch focusin on new element (bubbles, composed) // Dispatch focusin on new element (bubbles, composed)
const focusin_event = try FocusEvent.initTrusted(comptime .wrap("focusin"), .{ .bubbles = true, .composed = true, .relatedTarget = old_related }, page); const focusin_event = try FocusEvent.initTrusted(comptime .wrap("focusin"), .{ .bubbles = true, .composed = true, .relatedTarget = old_related }, page);
defer if (!focusin_event.asEvent()._v8_handoff) focusin_event.deinit(false);
try page._event_manager.dispatch(new_target, focusin_event.asEvent()); try page._event_manager.dispatch(new_target, focusin_event.asEvent());
} }
@@ -900,12 +896,10 @@ pub fn blur(self: *Element, page: *Page) !void {
// Dispatch blur (no bubble, composed) // Dispatch blur (no bubble, composed)
const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true }, page); const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true }, page);
defer if (!blur_event.asEvent()._v8_handoff) blur_event.deinit(false);
try page._event_manager.dispatch(old_target, blur_event.asEvent()); try page._event_manager.dispatch(old_target, blur_event.asEvent());
// Dispatch focusout (bubbles, composed) // Dispatch focusout (bubbles, composed)
const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true }, page); const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true }, page);
defer if (!focusout_event.asEvent()._v8_handoff) focusout_event.deinit(false);
try page._event_manager.dispatch(old_target, focusout_event.asEvent()); try page._event_manager.dispatch(old_target, focusout_event.asEvent());
} }

View File

@@ -25,12 +25,12 @@ const Node = @import("Node.zig");
const String = @import("../../string.zig").String; const String = @import("../../string.zig").String;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const Event = @This(); pub const Event = @This();
pub const _prototype_root = true; pub const _prototype_root = true;
_type: Type, _type: Type,
_page: *Page,
_arena: Allocator, _arena: Allocator,
_bubbles: bool = false, _bubbles: bool = false,
_cancelable: bool = false, _cancelable: bool = false,
@@ -45,13 +45,16 @@ _stop_immediate_propagation: bool = false,
_event_phase: EventPhase = .none, _event_phase: EventPhase = .none,
_time_stamp: u64, _time_stamp: u64,
_needs_retargeting: bool = false, _needs_retargeting: bool = false,
_isTrusted: bool = false, _is_trusted: bool = false,
// There's a period of time between creating an event and handing it off to v8 // There's a period of time between creating an event and handing it off to v8
// where things can fail. If it does fail, we need to deinit the event. This flag // where things can fail. If it does fail, we need to deinit the event. The timing
// when true, tells us the event is registered in the js.Contxt and thus, at // window can be difficult to capture, so we use a reference count.
// the very least, will be finalized on context shutdown. // should be 0, 1, or 2. 0
_v8_handoff: bool = false, // - 0: no reference, always a transient state going to either 1 or about to be deinit'd
// - 1: either zig or v8 have a reference
// - 2: both zig and v8 have a reference
_rc: u8 = 0,
pub const EventPhase = enum(u8) { pub const EventPhase = enum(u8) {
none = 0, none = 0,
@@ -84,16 +87,16 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event {
const arena = try page.getArena(.{ .debug = "Event" }); const arena = try page.getArena(.{ .debug = "Event" });
errdefer page.releaseArena(arena); errdefer page.releaseArena(arena);
const str = try String.init(arena, typ, .{}); const str = try String.init(arena, typ, .{});
return initWithTrusted(arena, str, opts_, false, page); return initWithTrusted(arena, str, opts_, false);
} }
pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event { pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event {
const arena = try page.getArena(.{ .debug = "Event.trusted" }); const arena = try page.getArena(.{ .debug = "Event.trusted" });
errdefer page.releaseArena(arena); errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, opts_, true, page); return initWithTrusted(arena, typ, opts_, true);
} }
fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*Event { fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, comptime trusted: bool) !*Event {
const opts = opts_ orelse Options{}; const opts = opts_ orelse Options{};
// Round to 2ms for privacy (browsers do this) // Round to 2ms for privacy (browsers do this)
@@ -102,7 +105,6 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool
const event = try arena.create(Event); const event = try arena.create(Event);
event.* = .{ event.* = .{
._page = page,
._arena = arena, ._arena = arena,
._type = .generic, ._type = .generic,
._bubbles = opts.bubbles, ._bubbles = opts.bubbles,
@@ -110,7 +112,7 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool
._cancelable = opts.cancelable, ._cancelable = opts.cancelable,
._composed = opts.composed, ._composed = opts.composed,
._type_string = typ, ._type_string = typ,
._isTrusted = trusted, ._is_trusted = trusted,
}; };
return event; return event;
} }
@@ -133,9 +135,26 @@ pub fn initEvent(
self._prevent_default = false; self._prevent_default = false;
} }
pub fn deinit(self: *Event, shutdown: bool) void { pub fn acquireRef(self: *Event) void {
_ = shutdown; self._rc += 1;
self._page.releaseArena(self._arena); }
pub fn deinit(self: *Event, shutdown: bool, page: *Page) void {
if (shutdown) {
page.releaseArena(self._arena);
return;
}
const rc = self._rc;
if (comptime IS_DEBUG) {
std.debug.assert(rc != 0);
}
if (rc == 1) {
page.releaseArena(self._arena);
} else {
self._rc = rc - 1;
}
} }
pub fn as(self: *Event, comptime T: type) *T { pub fn as(self: *Event, comptime T: type) *T {
@@ -237,15 +256,15 @@ pub fn getTimeStamp(self: *const Event) u64 {
} }
pub fn setTrusted(self: *Event) void { pub fn setTrusted(self: *Event) void {
self._isTrusted = true; self._is_trusted = true;
} }
pub fn setUntrusted(self: *Event) void { pub fn setUntrusted(self: *Event) void {
self._isTrusted = false; self._is_trusted = false;
} }
pub fn getIsTrusted(self: *const Event) bool { pub fn getIsTrusted(self: *const Event) bool {
return self._isTrusted; return self._is_trusted;
} }
pub fn composedPath(self: *Event, page: *Page) ![]const *EventTarget { pub fn composedPath(self: *Event, page: *Page) ![]const *EventTarget {
@@ -403,8 +422,8 @@ pub fn populatePrototypes(self: anytype, opts: anytype, trusted: bool) void {
} }
// Set isTrusted at the Event level (base of prototype chain) // Set isTrusted at the Event level (base of prototype chain)
if (T == Event or @hasField(T, "_isTrusted")) { if (T == Event or @hasField(T, "is_trusted")) {
self._isTrusted = trusted; self._is_trusted = trusted;
} }
} }

View File

@@ -43,6 +43,7 @@ pub const Type = union(enum) {
screen: *@import("Screen.zig"), screen: *@import("Screen.zig"),
screen_orientation: *@import("Screen.zig").Orientation, screen_orientation: *@import("Screen.zig").Orientation,
visual_viewport: *@import("VisualViewport.zig"), visual_viewport: *@import("VisualViewport.zig"),
file_reader: *@import("FileReader.zig"),
}; };
pub fn init(page: *Page) !*EventTarget { pub fn init(page: *Page) !*EventTarget {
@@ -55,7 +56,10 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool {
if (event._event_phase != .none) { if (event._event_phase != .none) {
return error.InvalidStateError; return error.InvalidStateError;
} }
event._isTrusted = false; event._is_trusted = false;
event.acquireRef();
defer event.deinit(false, page);
try page._event_manager.dispatch(self, event); try page._event_manager.dispatch(self, event);
return !event._cancelable or !event._prevent_default; return !event._cancelable or !event._prevent_default;
} }
@@ -151,6 +155,7 @@ pub fn toString(self: *EventTarget) []const u8 {
.screen => return "[object Screen]", .screen => return "[object Screen]",
.screen_orientation => return "[object ScreenOrientation]", .screen_orientation => return "[object ScreenOrientation]",
.visual_viewport => return "[object VisualViewport]", .visual_viewport => return "[object VisualViewport]",
.file_reader => return "[object FileReader]",
}; };
} }

View File

@@ -0,0 +1,358 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const EventTarget = @import("EventTarget.zig");
const ProgressEvent = @import("event/ProgressEvent.zig");
const Blob = @import("Blob.zig");
const Allocator = std.mem.Allocator;
/// https://w3c.github.io/FileAPI/#dfn-filereader
/// https://developer.mozilla.org/en-US/docs/Web/API/FileReader
const FileReader = @This();
_page: *Page,
_proto: *EventTarget,
_arena: Allocator,
_ready_state: ReadyState = .empty,
_result: ?Result = null,
_error: ?[]const u8 = null,
_on_abort: ?js.Function.Temp = null,
_on_error: ?js.Function.Temp = null,
_on_load: ?js.Function.Temp = null,
_on_load_end: ?js.Function.Temp = null,
_on_load_start: ?js.Function.Temp = null,
_on_progress: ?js.Function.Temp = null,
_aborted: bool = false,
const ReadyState = enum(u8) {
empty = 0,
loading = 1,
done = 2,
};
const Result = union(enum) {
string: []const u8,
arraybuffer: js.ArrayBuffer,
};
pub fn init(page: *Page) !*FileReader {
const arena = try page.getArena(.{ .debug = "FileReader" });
errdefer page.releaseArena(arena);
return page._factory.eventTargetWithAllocator(arena, FileReader{
._page = page,
._arena = arena,
._proto = undefined,
});
}
pub fn deinit(self: *FileReader, _: bool, page: *Page) void {
const js_ctx = page.js;
if (self._on_abort) |func| js_ctx.release(func);
if (self._on_error) |func| js_ctx.release(func);
if (self._on_load) |func| js_ctx.release(func);
if (self._on_load_end) |func| js_ctx.release(func);
if (self._on_load_start) |func| js_ctx.release(func);
if (self._on_progress) |func| js_ctx.release(func);
page.releaseArena(self._arena);
}
fn asEventTarget(self: *FileReader) *EventTarget {
return self._proto;
}
pub fn getOnAbort(self: *const FileReader) ?js.Function.Temp {
return self._on_abort;
}
pub fn setOnAbort(self: *FileReader, cb: ?js.Function.Temp) !void {
self._on_abort = cb;
}
pub fn getOnError(self: *const FileReader) ?js.Function.Temp {
return self._on_error;
}
pub fn setOnError(self: *FileReader, cb: ?js.Function.Temp) !void {
self._on_error = cb;
}
pub fn getOnLoad(self: *const FileReader) ?js.Function.Temp {
return self._on_load;
}
pub fn setOnLoad(self: *FileReader, cb: ?js.Function.Temp) !void {
self._on_load = cb;
}
pub fn getOnLoadEnd(self: *const FileReader) ?js.Function.Temp {
return self._on_load_end;
}
pub fn setOnLoadEnd(self: *FileReader, cb: ?js.Function.Temp) !void {
self._on_load_end = cb;
}
pub fn getOnLoadStart(self: *const FileReader) ?js.Function.Temp {
return self._on_load_start;
}
pub fn setOnLoadStart(self: *FileReader, cb: ?js.Function.Temp) !void {
self._on_load_start = cb;
}
pub fn getOnProgress(self: *const FileReader) ?js.Function.Temp {
return self._on_progress;
}
pub fn setOnProgress(self: *FileReader, cb: ?js.Function.Temp) !void {
self._on_progress = cb;
}
pub fn getReadyState(self: *const FileReader) u8 {
return @intFromEnum(self._ready_state);
}
pub fn getResult(self: *const FileReader) ?Result {
return self._result;
}
pub fn getError(self: *const FileReader) ?[]const u8 {
return self._error;
}
pub fn readAsArrayBuffer(self: *FileReader, blob: *Blob) !void {
try self.readInternal(blob, .arraybuffer);
}
pub fn readAsBinaryString(self: *FileReader, blob: *Blob) !void {
try self.readInternal(blob, .binary_string);
}
pub fn readAsText(self: *FileReader, blob: *Blob, encoding_: ?[]const u8) !void {
_ = encoding_; // TODO: Handle encoding properly
try self.readInternal(blob, .text);
}
pub fn readAsDataURL(self: *FileReader, blob: *Blob) !void {
try self.readInternal(blob, .data_url);
}
const ReadType = enum {
arraybuffer,
binary_string,
text,
data_url,
};
fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void {
if (self._ready_state == .loading) {
return error.InvalidStateError;
}
// Reset state
self._ready_state = .loading;
self._result = null;
self._error = null;
self._aborted = false;
const page = self._page;
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
const local = &ls.local;
try self.dispatch(.load_start, .{ .loaded = 0, .total = blob.getSize() }, local, page);
if (self._aborted) {
return;
}
// Perform the read (synchronous since data is in memory)
const data = blob._slice;
const size = data.len;
try self.dispatch(.progress, .{ .loaded = size, .total = size }, local, page);
if (self._aborted) {
return;
}
// Process the data based on read type
self._result = switch (read_type) {
.arraybuffer => .{ .arraybuffer = .{ .values = data } },
.binary_string => .{ .string = data },
.text => .{ .string = data },
.data_url => blk: {
// Create data URL with base64 encoding
const mime = if (blob._mime.len > 0) blob._mime else "application/octet-stream";
const data_url = try encodeDataURL(self._arena, mime, data);
break :blk .{ .string = data_url };
},
};
self._ready_state = .done;
try self.dispatch(.load, .{ .loaded = size, .total = size }, local, page);
try self.dispatch(.load_end, .{ .loaded = size, .total = size }, local, page);
}
pub fn abort(self: *FileReader) !void {
if (self._ready_state != .loading) {
return;
}
self._aborted = true;
self._ready_state = .done;
self._result = null;
const page = self._page;
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
const local = &ls.local;
try self.dispatch(.abort, null, local, page);
try self.dispatch(.load_end, null, local, page);
}
fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Progress, local: *const js.Local, page: *Page) !void {
const field, const typ = comptime blk: {
break :blk switch (event_type) {
.abort => .{ "_on_abort", "abort" },
.err => .{ "_on_error", "error" },
.load => .{ "_on_load", "load" },
.load_end => .{ "_on_load_end", "loadend" },
.load_start => .{ "_on_load_start", "loadstart" },
.progress => .{ "_on_progress", "progress" },
};
};
const progress = progress_ orelse Progress{};
const event = (try ProgressEvent.initTrusted(
comptime .wrap(typ),
.{ .total = progress.total, .loaded = progress.loaded },
page,
)).asEvent();
return page._event_manager.dispatchWithFunction(
self.asEventTarget(),
event,
local.toLocal(@field(self, field)),
.{ .context = "FileReader " ++ typ },
);
}
const DispatchType = enum {
abort,
err,
load,
load_end,
load_start,
progress,
};
const Progress = struct {
loaded: usize = 0,
total: usize = 0,
};
/// Encodes binary data as a data URL with base64 encoding.
/// Format: data:[<mediatype>][;base64],<data>
fn encodeDataURL(arena: Allocator, mime: []const u8, data: []const u8) ![]const u8 {
const base64 = std.base64.standard.Encoder;
// Calculate size needed for base64 encoding
const encoded_size = base64.calcSize(data.len);
// Allocate buffer for the full data URL
// Format: "data:" + mime + ";base64," + encoded_data
const prefix = "data:";
const suffix = ";base64,";
const total_size = prefix.len + mime.len + suffix.len + encoded_size;
var pos: usize = 0;
const buf = try arena.alloc(u8, total_size);
@memcpy(buf[pos..][0..prefix.len], prefix);
pos += prefix.len;
@memcpy(buf[pos..][0..mime.len], mime);
pos += mime.len;
@memcpy(buf[pos..][0..suffix.len], suffix);
pos += suffix.len;
_ = base64.encode(buf[pos..], data);
return buf;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FileReader);
pub const Meta = struct {
pub const name = "FileReader";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(FileReader.deinit);
};
pub const constructor = bridge.constructor(FileReader.init, .{});
// State constants
pub const EMPTY = bridge.property(@intFromEnum(FileReader.ReadyState.empty), .{ .template = true });
pub const LOADING = bridge.property(@intFromEnum(FileReader.ReadyState.loading), .{ .template = true });
pub const DONE = bridge.property(@intFromEnum(FileReader.ReadyState.done), .{ .template = true });
// Properties
pub const readyState = bridge.accessor(FileReader.getReadyState, null, .{});
pub const result = bridge.accessor(FileReader.getResult, null, .{});
pub const @"error" = bridge.accessor(FileReader.getError, null, .{});
// Event handlers
pub const onabort = bridge.accessor(FileReader.getOnAbort, FileReader.setOnAbort, .{});
pub const onerror = bridge.accessor(FileReader.getOnError, FileReader.setOnError, .{});
pub const onload = bridge.accessor(FileReader.getOnLoad, FileReader.setOnLoad, .{});
pub const onloadend = bridge.accessor(FileReader.getOnLoadEnd, FileReader.setOnLoadEnd, .{});
pub const onloadstart = bridge.accessor(FileReader.getOnLoadStart, FileReader.setOnLoadStart, .{});
pub const onprogress = bridge.accessor(FileReader.getOnProgress, FileReader.setOnProgress, .{});
// Methods
pub const readAsArrayBuffer = bridge.function(FileReader.readAsArrayBuffer, .{ .dom_exception = true });
pub const readAsBinaryString = bridge.function(FileReader.readAsBinaryString, .{ .dom_exception = true });
pub const readAsText = bridge.function(FileReader.readAsText, .{ .dom_exception = true });
pub const readAsDataURL = bridge.function(FileReader.readAsDataURL, .{ .dom_exception = true });
pub const abort = bridge.function(FileReader.abort, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: FileReader" {
try testing.htmlRunner("file_reader.html", .{});
}

View File

@@ -80,8 +80,6 @@ fn goInner(delta: i32, page: *Page) !void {
if (entry._url) |url| { if (entry._url) |url| {
if (try page.isSameOrigin(url)) { if (try page.isSameOrigin(url)) {
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent(); const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatchWithFunction( try page._event_manager.dispatchWithFunction(
page.window.asEventTarget(), page.window.asEventTarget(),
event, event,

View File

@@ -20,6 +20,9 @@ const std = @import("std");
const IdleDeadline = @This(); const IdleDeadline = @This();
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
pub fn init() IdleDeadline { pub fn init() IdleDeadline {
return .{}; return .{};
} }

View File

@@ -93,14 +93,6 @@ pub fn getHeight(self: *const ImageData) u32 {
return self._height; return self._height;
} }
pub fn getPixelFormat(_: *const ImageData) String {
return comptime .wrap("rgba-unorm8");
}
pub fn getColorSpace(_: *const ImageData) String {
return comptime .wrap("srgb");
}
pub fn getData(self: *const ImageData) js.ArrayBufferRef(.uint8_clamped).Global { pub fn getData(self: *const ImageData) js.ArrayBufferRef(.uint8_clamped).Global {
return self._data; return self._data;
} }
@@ -116,11 +108,12 @@ pub const JsApi = struct {
pub const constructor = bridge.constructor(ImageData.constructor, .{ .dom_exception = true }); pub const constructor = bridge.constructor(ImageData.constructor, .{ .dom_exception = true });
pub const colorSpace = bridge.property("srgb", .{ .template = false, .readonly = true });
pub const pixelFormat = bridge.property("rgba-unorm8", .{ .template = false, .readonly = true });
pub const data = bridge.accessor(ImageData.getData, null, .{});
pub const width = bridge.accessor(ImageData.getWidth, null, .{}); pub const width = bridge.accessor(ImageData.getWidth, null, .{});
pub const height = bridge.accessor(ImageData.getHeight, null, .{}); pub const height = bridge.accessor(ImageData.getHeight, null, .{});
pub const pixelFormat = bridge.accessor(ImageData.getPixelFormat, null, .{});
pub const colorSpace = bridge.accessor(ImageData.getColorSpace, null, .{});
pub const data = bridge.accessor(ImageData.getData, null, .{});
}; };
const testing = @import("../../testing.zig"); const testing = @import("../../testing.zig");

View File

@@ -36,7 +36,6 @@ pub fn registerTypes() []const type {
const IntersectionObserver = @This(); const IntersectionObserver = @This();
_page: *Page,
_arena: Allocator, _arena: Allocator,
_callback: js.Function.Temp, _callback: js.Function.Temp,
_observing: std.ArrayList(*Element) = .{}, _observing: std.ArrayList(*Element) = .{},
@@ -83,7 +82,6 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
const self = try arena.create(IntersectionObserver); const self = try arena.create(IntersectionObserver);
self.* = .{ self.* = .{
._page = page,
._arena = arena, ._arena = arena,
._callback = callback, ._callback = callback,
._root = opts.root, ._root = opts.root,
@@ -93,8 +91,7 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
return self; return self;
} }
pub fn deinit(self: *IntersectionObserver, shutdown: bool) void { pub fn deinit(self: *IntersectionObserver, shutdown: bool, page: *Page) void {
const page = self._page;
page.js.release(self._callback); page.js.release(self._callback);
if ((comptime IS_DEBUG) and !shutdown) { if ((comptime IS_DEBUG) and !shutdown) {
std.debug.assert(self._observing.items.len == 0); std.debug.assert(self._observing.items.len == 0);
@@ -140,7 +137,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
while (j < self._pending_entries.items.len) { while (j < self._pending_entries.items.len) {
if (self._pending_entries.items[j]._target == target) { if (self._pending_entries.items[j]._target == target) {
const entry = self._pending_entries.swapRemove(j); const entry = self._pending_entries.swapRemove(j);
entry.deinit(false); entry.deinit(false, page);
} else { } else {
j += 1; j += 1;
} }
@@ -160,7 +157,7 @@ pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
self._previous_states.clearRetainingCapacity(); self._previous_states.clearRetainingCapacity();
for (self._pending_entries.items) |entry| { for (self._pending_entries.items) |entry| {
entry.deinit(false); entry.deinit(false, page);
} }
self._pending_entries.clearRetainingCapacity(); self._pending_entries.clearRetainingCapacity();
page.js.safeWeakRef(self); page.js.safeWeakRef(self);
@@ -245,7 +242,6 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page)
const entry = try arena.create(IntersectionObserverEntry); const entry = try arena.create(IntersectionObserverEntry);
entry.* = .{ entry.* = .{
._page = page,
._arena = arena, ._arena = arena,
._target = target, ._target = target,
._time = page.window._performance.now(), ._time = page.window._performance.now(),
@@ -297,7 +293,6 @@ pub fn deliverEntries(self: *IntersectionObserver, page: *Page) !void {
} }
pub const IntersectionObserverEntry = struct { pub const IntersectionObserverEntry = struct {
_page: *Page,
_arena: Allocator, _arena: Allocator,
_time: f64, _time: f64,
_target: *Element, _target: *Element,
@@ -307,8 +302,8 @@ pub const IntersectionObserverEntry = struct {
_intersection_ratio: f64, _intersection_ratio: f64,
_is_intersecting: bool, _is_intersecting: bool,
pub fn deinit(self: *const IntersectionObserverEntry, _: bool) void { pub fn deinit(self: *const IntersectionObserverEntry, _: bool, page: *Page) void {
self._page.releaseArena(self._arena); page.releaseArena(self._arena);
} }
pub fn getTarget(self: *const IntersectionObserverEntry) *Element { pub fn getTarget(self: *const IntersectionObserverEntry) *Element {

View File

@@ -62,7 +62,7 @@ pub fn copy(arena: Allocator, original: KeyValueList) !KeyValueList {
} }
pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList {
var it = js_obj.nameIterator(); var it = try js_obj.nameIterator();
var list = KeyValueList.init(); var list = KeyValueList.init();
try list.ensureTotalCapacity(arena, it.count); try list.ensureTotalCapacity(arena, it.count);

View File

@@ -130,7 +130,6 @@ const PostMessageCallback = struct {
log.err(.dom, "MessagePort.postMessage", .{ .err = err }); log.err(.dom, "MessagePort.postMessage", .{ .err = err });
return null; return null;
}).asEvent(); }).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
page.js.localScope(&ls); page.js.localScope(&ls);

View File

@@ -38,7 +38,6 @@ pub fn registerTypes() []const type {
const MutationObserver = @This(); const MutationObserver = @This();
_page: *Page,
_arena: Allocator, _arena: Allocator,
_callback: js.Function.Temp, _callback: js.Function.Temp,
_observing: std.ArrayList(Observing) = .{}, _observing: std.ArrayList(Observing) = .{},
@@ -79,15 +78,13 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
const self = try arena.create(MutationObserver); const self = try arena.create(MutationObserver);
self.* = .{ self.* = .{
._page = page,
._arena = arena, ._arena = arena,
._callback = callback, ._callback = callback,
}; };
return self; return self;
} }
pub fn deinit(self: *MutationObserver, shutdown: bool) void { pub fn deinit(self: *MutationObserver, shutdown: bool, page: *Page) void {
const page = self._page;
page.js.release(self._callback); page.js.release(self._callback);
if ((comptime IS_DEBUG) and !shutdown) { if ((comptime IS_DEBUG) and !shutdown) {
std.debug.assert(self._observing.items.len == 0); std.debug.assert(self._observing.items.len == 0);
@@ -174,7 +171,7 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void {
page.unregisterMutationObserver(self); page.unregisterMutationObserver(self);
self._observing.clearRetainingCapacity(); self._observing.clearRetainingCapacity();
for (self._pending_records.items) |record| { for (self._pending_records.items) |record| {
record.deinit(false); record.deinit(false, page);
} }
self._pending_records.clearRetainingCapacity(); self._pending_records.clearRetainingCapacity();
page.js.safeWeakRef(self); page.js.safeWeakRef(self);
@@ -218,10 +215,9 @@ pub fn notifyAttributeChange(
} }
} }
const arena = try self._page.getArena(.{ .debug = "MutationRecord" }); const arena = try page.getArena(.{ .debug = "MutationRecord" });
const record = try arena.create(MutationRecord); const record = try arena.create(MutationRecord);
record.* = .{ record.* = .{
._page = page,
._arena = arena, ._arena = arena,
._type = .attributes, ._type = .attributes,
._target = target_node, ._target = target_node,
@@ -247,7 +243,7 @@ pub fn notifyAttributeChange(
pub fn notifyCharacterDataChange( pub fn notifyCharacterDataChange(
self: *MutationObserver, self: *MutationObserver,
target: *Node, target: *Node,
old_value: ?[]const u8, old_value: ?String,
page: *Page, page: *Page,
) !void { ) !void {
for (self._observing.items) |obs| { for (self._observing.items) |obs| {
@@ -263,16 +259,15 @@ pub fn notifyCharacterDataChange(
continue; continue;
} }
const arena = try self._page.getArena(.{ .debug = "MutationRecord" }); const arena = try page.getArena(.{ .debug = "MutationRecord" });
const record = try arena.create(MutationRecord); const record = try arena.create(MutationRecord);
record.* = .{ record.* = .{
._page = page,
._arena = arena, ._arena = arena,
._type = .characterData, ._type = .characterData,
._target = target, ._target = target,
._attribute_name = null, ._attribute_name = null,
._old_value = if (obs.options.characterDataOldValue and old_value != null) ._old_value = if (obs.options.characterDataOldValue and old_value != null)
try arena.dupe(u8, old_value.?) try arena.dupe(u8, old_value.?.str())
else else
null, null,
._added_nodes = &.{}, ._added_nodes = &.{},
@@ -311,10 +306,9 @@ pub fn notifyChildListChange(
continue; continue;
} }
const arena = try self._page.getArena(.{ .debug = "MutationRecord" }); const arena = try page.getArena(.{ .debug = "MutationRecord" });
const record = try arena.create(MutationRecord); const record = try arena.create(MutationRecord);
record.* = .{ record.* = .{
._page = page,
._arena = arena, ._arena = arena,
._type = .childList, ._type = .childList,
._target = target, ._target = target,
@@ -354,7 +348,6 @@ pub fn deliverRecords(self: *MutationObserver, page: *Page) !void {
pub const MutationRecord = struct { pub const MutationRecord = struct {
_type: Type, _type: Type,
_page: *Page,
_target: *Node, _target: *Node,
_arena: Allocator, _arena: Allocator,
_attribute_name: ?[]const u8, _attribute_name: ?[]const u8,
@@ -370,8 +363,8 @@ pub const MutationRecord = struct {
characterData, characterData,
}; };
pub fn deinit(self: *const MutationRecord, _: bool) void { pub fn deinit(self: *const MutationRecord, _: bool, page: *Page) void {
self._page.releaseArena(self._arena); page.releaseArena(self._arena);
} }
pub fn getType(self: *const MutationRecord) []const u8 { pub fn getType(self: *const MutationRecord) []const u8 {

View File

@@ -270,7 +270,7 @@ pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!vo
try child.getTextContent(writer); try child.getTextContent(writer);
} }
}, },
.cdata => |c| try writer.writeAll(c.getData()), .cdata => |c| try writer.writeAll(c._data.str()),
.document => {}, .document => {},
.document_type => {}, .document_type => {},
.attribute => |attr| try writer.writeAll(attr._value.str()), .attribute => |attr| try writer.writeAll(attr._value.str()),
@@ -293,7 +293,7 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void {
} }
return el.replaceChildren(&.{.{ .text = data }}, page); return el.replaceChildren(&.{.{ .text = data }}, page);
}, },
.cdata => |c| c._data = try page.arena.dupe(u8, data), .cdata => |c| c._data = try page.dupeSSO(data),
.document => {}, .document => {},
.document_type => {}, .document_type => {},
.document_fragment => |frag| { .document_fragment => |frag| {
@@ -599,10 +599,10 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, page: *Page
return old_child; return old_child;
} }
pub fn getNodeValue(self: *const Node) ?[]const u8 { pub fn getNodeValue(self: *const Node) ?String {
return switch (self._type) { return switch (self._type) {
.cdata => |c| c.getData(), .cdata => |c| c.getData(),
.attribute => |attr| attr._value.str(), .attribute => |attr| attr._value,
.element => null, .element => null,
.document => null, .document => null,
.document_type => null, .document_type => null,
@@ -694,10 +694,10 @@ pub fn getChildAt(self: *Node, index: u32) ?*Node {
return null; return null;
} }
pub fn getData(self: *const Node) []const u8 { pub fn getData(self: *const Node) String {
return switch (self._type) { return switch (self._type) {
.cdata => |c| c.getData(), .cdata => |c| c.getData(),
else => "", else => .empty,
}; };
} }
@@ -713,11 +713,23 @@ pub fn normalize(self: *Node, page: *Page) !void {
return self._normalize(page.call_arena, &buffer, page); return self._normalize(page.call_arena, &buffer, page);
} }
pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, StringTooLarge, NotSupported, NotImplemented, InvalidCharacterError, CloneError, IFrameLoadError }!*Node { const CloneError = error{
OutOfMemory,
StringTooLarge,
NotSupported,
NotImplemented,
InvalidCharacterError,
CloneError,
IFrameLoadError,
TooManyContexts,
LinkLoadError,
StyleLoadError,
};
pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node {
const deep = deep_ orelse false; const deep = deep_ orelse false;
switch (self._type) { switch (self._type) {
.cdata => |cd| { .cdata => |cd| {
const data = cd.getData(); const data = cd.getData().str();
return switch (cd._type) { return switch (cd._type) {
.text => page.createTextNode(data), .text => page.createTextNode(data),
.cdata_section => page.createCDATASection(data), .cdata_section => page.createCDATASection(data),
@@ -872,7 +884,7 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), pag
next_node = node_to_merge.nextSibling(); next_node = node_to_merge.nextSibling();
page.removeNode(self, to_remove, .{ .will_be_reconnected = false }); page.removeNode(self, to_remove, .{ .will_be_reconnected = false });
} }
text_node._proto._data = try page.dupeString(buffer.items); text_node._proto._data = try page.dupeSSO(buffer.items);
buffer.clearRetainingCapacity(); buffer.clearRetainingCapacity();
} }
} }
@@ -1016,7 +1028,7 @@ pub const JsApi = struct {
try self.getTextContent(&buf.writer); try self.getTextContent(&buf.writer);
return buf.written(); return buf.written();
}, },
.cdata => |cdata| return cdata.getData(), .cdata => |cdata| return cdata._data.str(),
.attribute => |attr| return attr._value.str(), .attribute => |attr| return attr._value.str(),
.document => return null, .document => return null,
.document_type => return null, .document_type => return null,

View File

@@ -3,7 +3,7 @@ const Page = @import("../Page.zig");
const datetime = @import("../../datetime.zig"); const datetime = @import("../../datetime.zig");
pub fn registerTypes() []const type { pub fn registerTypes() []const type {
return &.{ Performance, Entry, Mark, Measure }; return &.{ Performance, Entry, Mark, Measure, PerformanceTiming, PerformanceNavigation };
} }
const std = @import("std"); const std = @import("std");
@@ -12,6 +12,8 @@ const Performance = @This();
_time_origin: u64, _time_origin: u64,
_entries: std.ArrayList(*Entry) = .{}, _entries: std.ArrayList(*Entry) = .{},
_timing: PerformanceTiming = .{},
_navigation: PerformanceNavigation = .{},
/// Get high-resolution timestamp in microseconds, rounded to 5μs increments /// Get high-resolution timestamp in microseconds, rounded to 5μs increments
/// to match browser behavior (prevents fingerprinting) /// to match browser behavior (prevents fingerprinting)
@@ -27,9 +29,15 @@ pub fn init() Performance {
return .{ return .{
._time_origin = highResTimestamp(), ._time_origin = highResTimestamp(),
._entries = .{}, ._entries = .{},
._timing = .{},
._navigation = .{},
}; };
} }
pub fn getTiming(self: *Performance) *PerformanceTiming {
return &self._timing;
}
pub fn now(self: *const Performance) f64 { pub fn now(self: *const Performance) f64 {
const current = highResTimestamp(); const current = highResTimestamp();
const elapsed = current - self._time_origin; const elapsed = current - self._time_origin;
@@ -42,6 +50,10 @@ pub fn getTimeOrigin(self: *const Performance) f64 {
return @as(f64, @floatFromInt(self._time_origin)) / 1000.0; return @as(f64, @floatFromInt(self._time_origin)) / 1000.0;
} }
pub fn getNavigation(self: *Performance) *PerformanceNavigation {
return &self._navigation;
}
pub fn mark( pub fn mark(
self: *Performance, self: *Performance,
name: []const u8, name: []const u8,
@@ -208,9 +220,37 @@ fn getMarkTime(self: *const Performance, mark_name: []const u8) !f64 {
} }
} }
// Recognized mark names by browsers. `navigationStart` is an equivalent // PerformanceTiming attribute names are valid start/end marks per the
// to 0. Others are dependant to request arrival, end of request etc. // W3C User Timing Level 2 spec. All are relative to navigationStart (= 0).
if (std.mem.eql(u8, "navigationStart", mark_name)) { // https://www.w3.org/TR/user-timing/#dom-performance-measure
//
// `navigationStart` is an equivalent to 0.
// Others are dependant to request arrival, end of request etc, but we
// return a dummy 0 value for now.
const navigation_timing_marks = std.StaticStringMap(void).initComptime(.{
.{ "navigationStart", {} },
.{ "unloadEventStart", {} },
.{ "unloadEventEnd", {} },
.{ "redirectStart", {} },
.{ "redirectEnd", {} },
.{ "fetchStart", {} },
.{ "domainLookupStart", {} },
.{ "domainLookupEnd", {} },
.{ "connectStart", {} },
.{ "connectEnd", {} },
.{ "secureConnectionStart", {} },
.{ "requestStart", {} },
.{ "responseStart", {} },
.{ "responseEnd", {} },
.{ "domLoading", {} },
.{ "domInteractive", {} },
.{ "domContentLoadedEventStart", {} },
.{ "domContentLoadedEventEnd", {} },
.{ "domComplete", {} },
.{ "loadEventStart", {} },
.{ "loadEventEnd", {} },
});
if (navigation_timing_marks.has(mark_name)) {
return 0; return 0;
} }
@@ -235,6 +275,8 @@ pub const JsApi = struct {
pub const getEntriesByType = bridge.function(Performance.getEntriesByType, .{}); pub const getEntriesByType = bridge.function(Performance.getEntriesByType, .{});
pub const getEntriesByName = bridge.function(Performance.getEntriesByName, .{}); pub const getEntriesByName = bridge.function(Performance.getEntriesByName, .{});
pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{}); pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{});
pub const timing = bridge.accessor(Performance.getTiming, null, .{});
pub const navigation = bridge.accessor(Performance.getNavigation, null, .{});
}; };
pub const Entry = struct { pub const Entry = struct {
@@ -421,6 +463,70 @@ pub const Measure = struct {
}; };
}; };
/// PerformanceTiming — Navigation Timing Level 1 (legacy, but widely used).
/// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceTiming
/// All properties return 0 as stub values; the object must not be undefined
/// so that scripts accessing performance.timing.navigationStart don't crash.
pub const PerformanceTiming = struct {
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
pub const JsApi = struct {
pub const bridge = js.Bridge(PerformanceTiming);
pub const Meta = struct {
pub const name = "PerformanceTiming";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const navigationStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const unloadEventStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const unloadEventEnd = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const redirectStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const redirectEnd = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const fetchStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const domainLookupStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const domainLookupEnd = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const connectStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const connectEnd = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const secureConnectionStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const requestStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const responseStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const responseEnd = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const domLoading = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const domInteractive = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const domContentLoadedEventStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const domContentLoadedEventEnd = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const domComplete = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const loadEventStart = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const loadEventEnd = bridge.property(0.0, .{ .template = false, .readonly = true });
};
};
// PerformanceNavigation implements the Navigation Timing Level 1 API.
// https://www.w3.org/TR/navigation-timing/#sec-navigation-navigation-timing-interface
// Stub implementation — returns 0 for type (TYPE_NAVIGATE) and 0 for redirectCount.
pub const PerformanceNavigation = struct {
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
pub const JsApi = struct {
pub const bridge = js.Bridge(PerformanceNavigation);
pub const Meta = struct {
pub const name = "PerformanceNavigation";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const @"type" = bridge.property(0.0, .{ .template = false, .readonly = true });
pub const redirectCount = bridge.property(0.0, .{ .template = false, .readonly = true });
};
};
const testing = @import("../../testing.zig"); const testing = @import("../../testing.zig");
test "WebApi: Performance" { test "WebApi: Performance" {
try testing.htmlRunner("performance.html", .{}); try testing.htmlRunner("performance.html", .{});

View File

@@ -63,7 +63,7 @@ pub const JsApi = struct {
pub const length = bridge.property(0, .{ .template = false }); pub const length = bridge.property(0, .{ .template = false });
pub const refresh = bridge.function(PluginArray.refresh, .{}); pub const refresh = bridge.function(PluginArray.refresh, .{});
pub const @"[int]" = bridge.indexed(PluginArray.getAtIndex, .{ .null_as_undefined = true }); pub const @"[int]" = bridge.indexed(PluginArray.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(PluginArray.getByName, null, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(PluginArray.getByName, null, null, .{ .null_as_undefined = true });
pub const item = bridge.function(_item, .{}); pub const item = bridge.function(_item, .{});
fn _item(self: *const PluginArray, index: i32) ?*Plugin { fn _item(self: *const PluginArray, index: i32) ?*Plugin {

View File

@@ -17,9 +17,11 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const js = @import("../js/js.zig"); const String = @import("../../string.zig").String;
const js = @import("../js/js.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Node = @import("Node.zig"); const Node = @import("Node.zig");
const DocumentFragment = @import("DocumentFragment.zig"); const DocumentFragment = @import("DocumentFragment.zig");
const AbstractRange = @import("AbstractRange.zig"); const AbstractRange = @import("AbstractRange.zig");
@@ -326,7 +328,7 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
if (offset == 0) { if (offset == 0) {
_ = try parent.insertBefore(node, container, page); _ = try parent.insertBefore(node, container, page);
} else { } else {
const text_data = container.getData(); const text_data = container.getData().str();
if (offset >= text_data.len) { if (offset >= text_data.len) {
_ = try parent.insertBefore(node, container.nextSibling(), page); _ = try parent.insertBefore(node, container.nextSibling(), page);
} else { } else {
@@ -362,15 +364,15 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
// Simple case: same container // Simple case: same container
if (self._proto._start_container == self._proto._end_container) { if (self._proto._start_container == self._proto._end_container) {
if (self._proto._start_container.is(Node.CData)) |_| { if (self._proto._start_container.is(Node.CData)) |cdata| {
// Delete part of text node // Delete part of text node
const text_data = self._proto._start_container.getData(); const old_value = cdata.getData();
const new_text = try std.mem.concat( const text_data = old_value.str();
cdata._data = try String.concat(
page.arena, page.arena,
u8,
&.{ text_data[0..self._proto._start_offset], text_data[self._proto._end_offset..] }, &.{ text_data[0..self._proto._start_offset], text_data[self._proto._end_offset..] },
); );
try self._proto._start_container.setData(new_text, page); page.characterDataChange(self._proto._start_container, old_value);
} else { } else {
// Delete child nodes in range // Delete child nodes in range
var offset = self._proto._start_offset; var offset = self._proto._start_offset;
@@ -386,8 +388,8 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
// Complex case: different containers // Complex case: different containers
// Handle start container - if it's a text node, truncate it // Handle start container - if it's a text node, truncate it
if (self._proto._start_container.is(Node.CData)) |_| { if (self._proto._start_container.is(Node.CData)) |cdata| {
const text_data = self._proto._start_container.getData(); const text_data = cdata._data.str();
if (self._proto._start_offset < text_data.len) { if (self._proto._start_offset < text_data.len) {
// Keep only the part before start_offset // Keep only the part before start_offset
const new_text = text_data[0..self._proto._start_offset]; const new_text = text_data[0..self._proto._start_offset];
@@ -396,8 +398,8 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
} }
// Handle end container - if it's a text node, truncate it // Handle end container - if it's a text node, truncate it
if (self._proto._end_container.is(Node.CData)) |_| { if (self._proto._end_container.is(Node.CData)) |cdata| {
const text_data = self._proto._end_container.getData(); const text_data = cdata._data.str();
if (self._proto._end_offset < text_data.len) { if (self._proto._end_offset < text_data.len) {
// Keep only the part from end_offset onwards // Keep only the part from end_offset onwards
const new_text = text_data[self._proto._end_offset..]; const new_text = text_data[self._proto._end_offset..];
@@ -433,7 +435,7 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
if (self._proto._start_container == self._proto._end_container) { if (self._proto._start_container == self._proto._end_container) {
if (self._proto._start_container.is(Node.CData)) |_| { if (self._proto._start_container.is(Node.CData)) |_| {
// Clone part of text node // Clone part of text node
const text_data = self._proto._start_container.getData(); const text_data = self._proto._start_container.getData().str();
if (self._proto._start_offset < text_data.len and self._proto._end_offset <= text_data.len) { if (self._proto._start_offset < text_data.len and self._proto._end_offset <= text_data.len) {
const cloned_text = text_data[self._proto._start_offset..self._proto._end_offset]; const cloned_text = text_data[self._proto._start_offset..self._proto._end_offset];
const text_node = try page.createTextNode(cloned_text); const text_node = try page.createTextNode(cloned_text);
@@ -453,7 +455,7 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
// Complex case: different containers // Complex case: different containers
// Clone partial start container // Clone partial start container
if (self._proto._start_container.is(Node.CData)) |_| { if (self._proto._start_container.is(Node.CData)) |_| {
const text_data = self._proto._start_container.getData(); const text_data = self._proto._start_container.getData().str();
if (self._proto._start_offset < text_data.len) { if (self._proto._start_offset < text_data.len) {
// Clone from start_offset to end of text // Clone from start_offset to end of text
const cloned_text = text_data[self._proto._start_offset..]; const cloned_text = text_data[self._proto._start_offset..];
@@ -474,7 +476,7 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
// Clone partial end container // Clone partial end container
if (self._proto._end_container.is(Node.CData)) |_| { if (self._proto._end_container.is(Node.CData)) |_| {
const text_data = self._proto._end_container.getData(); const text_data = self._proto._end_container.getData().str();
if (self._proto._end_offset > 0 and self._proto._end_offset <= text_data.len) { if (self._proto._end_offset > 0 and self._proto._end_offset <= text_data.len) {
// Clone from start to end_offset // Clone from start to end_offset
const cloned_text = text_data[0..self._proto._end_offset]; const cloned_text = text_data[0..self._proto._end_offset];
@@ -560,7 +562,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
if (start_node == end_node) { if (start_node == end_node) {
if (start_node.is(Node.CData)) |cdata| { if (start_node.is(Node.CData)) |cdata| {
if (!isCommentOrPI(cdata)) { if (!isCommentOrPI(cdata)) {
const data = cdata.getData(); const data = cdata.getData().str();
const s = @min(start_offset, data.len); const s = @min(start_offset, data.len);
const e = @min(end_offset, data.len); const e = @min(end_offset, data.len);
try writer.writeAll(data[s..e]); try writer.writeAll(data[s..e]);
@@ -574,7 +576,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
// Partial start: if start container is a text node, write from offset to end // Partial start: if start container is a text node, write from offset to end
if (start_node.is(Node.CData)) |cdata| { if (start_node.is(Node.CData)) |cdata| {
if (!isCommentOrPI(cdata)) { if (!isCommentOrPI(cdata)) {
const data = cdata.getData(); const data = cdata.getData().str();
const s = @min(start_offset, data.len); const s = @min(start_offset, data.len);
try writer.writeAll(data[s..]); try writer.writeAll(data[s..]);
} }
@@ -601,7 +603,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
} }
if (n.is(Node.CData)) |cdata| { if (n.is(Node.CData)) |cdata| {
if (!isCommentOrPI(cdata)) { if (!isCommentOrPI(cdata)) {
try writer.writeAll(cdata.getData()); try writer.writeAll(cdata.getData().str());
} }
} }
current = nextInTreeOrder(n, root); current = nextInTreeOrder(n, root);
@@ -612,7 +614,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
if (start_node != end_node) { if (start_node != end_node) {
if (end_node.is(Node.CData)) |cdata| { if (end_node.is(Node.CData)) |cdata| {
if (!isCommentOrPI(cdata)) { if (!isCommentOrPI(cdata)) {
const data = cdata.getData(); const data = cdata.getData().str();
const e = @min(end_offset, data.len); const e = @min(end_offset, data.len);
try writer.writeAll(data[0..e]); try writer.writeAll(data[0..e]);
} }

View File

@@ -22,6 +22,9 @@ const Element = @import("Element.zig");
pub const ResizeObserver = @This(); pub const ResizeObserver = @This();
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
fn init(cbk: js.Function) ResizeObserver { fn init(cbk: js.Function) ResizeObserver {
_ = cbk; _ = cbk;
return .{}; return .{};

View File

@@ -39,7 +39,6 @@ pub const init: Selection = .{};
fn dispatchSelectionChangeEvent(page: *Page) !void { fn dispatchSelectionChangeEvent(page: *Page) !void {
const event = try Event.init("selectionchange", .{}, page); const event = try Event.init("selectionchange", .{}, page);
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatch(page.document.asEventTarget(), event); try page._event_manager.dispatch(page.document.asEventTarget(), event);
} }
@@ -291,16 +290,9 @@ const ModifyDirection = enum {
const ModifyGranularity = enum { const ModifyGranularity = enum {
character, character,
word, word,
line, // The rest are either:
paragraph, // 1. Layout dependent.
lineboundary, // 2. Not widely supported across browsers.
// Firefox doesn't implement:
// - sentence
// - paragraph
// - sentenceboundary
// - paragraphboundary
// - documentboundary
// so we won't either for now.
pub fn fromString(str: []const u8) ?ModifyGranularity { pub fn fromString(str: []const u8) ?ModifyGranularity {
return std.meta.stringToEnum(ModifyGranularity, str); return std.meta.stringToEnum(ModifyGranularity, str);
@@ -312,18 +304,268 @@ pub fn modify(
alter_str: []const u8, alter_str: []const u8,
direction_str: []const u8, direction_str: []const u8,
granularity_str: []const u8, granularity_str: []const u8,
page: *Page,
) !void { ) !void {
const alter = ModifyAlter.fromString(alter_str) orelse return error.InvalidParams; const alter = ModifyAlter.fromString(alter_str) orelse return;
const direction = ModifyDirection.fromString(direction_str) orelse return error.InvalidParams; const direction = ModifyDirection.fromString(direction_str) orelse return;
const granularity = ModifyGranularity.fromString(granularity_str) orelse return error.InvalidParams; const granularity = ModifyGranularity.fromString(granularity_str) orelse return;
_ = self._range orelse return; const range = self._range orelse return;
log.warn(.not_implemented, "Selection.modify", .{ const is_forward = switch (direction) {
.alter = alter, .forward, .right => true,
.direction = direction, .backward, .left => false,
.granularity = granularity, };
});
switch (granularity) {
.character => try self.modifyByCharacter(alter, is_forward, range, page),
.word => try self.modifyByWord(alter, is_forward, range, page),
}
}
fn isTextNode(node: *const Node) bool {
return switch (node._type) {
.cdata => |cd| cd._type == .text,
else => false,
};
}
fn nextTextNode(node: *Node) ?*Node {
var current = node;
while (true) {
if (current.firstChild()) |child| {
current = child;
} else if (current.nextSibling()) |sib| {
current = sib;
} else {
while (true) {
const parent = current.parentNode() orelse return null;
if (parent.nextSibling()) |uncle| {
current = uncle;
break;
}
current = parent;
}
}
if (isTextNode(current)) return current;
}
}
fn nextTextNodeAfter(node: *Node) ?*Node {
var current = node;
while (true) {
if (current.nextSibling()) |sib| {
current = sib;
} else {
while (true) {
const parent = current.parentNode() orelse return null;
if (parent.nextSibling()) |uncle| {
current = uncle;
break;
}
current = parent;
}
}
var descend = current;
while (true) {
if (isTextNode(descend)) return descend;
descend = descend.firstChild() orelse break;
}
}
}
fn prevTextNode(node: *Node) ?*Node {
var current = node;
while (true) {
if (current.previousSibling()) |sib| {
current = sib;
while (current.lastChild()) |child| {
current = child;
}
} else {
current = current.parentNode() orelse return null;
}
if (isTextNode(current)) return current;
}
}
fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: *Range, page: *Page) !void {
const abstract = range.asAbstractRange();
const focus_node = switch (self._direction) {
.backward => abstract.getStartContainer(),
.forward, .none => abstract.getEndContainer(),
};
const focus_offset = switch (self._direction) {
.backward => abstract.getStartOffset(),
.forward, .none => abstract.getEndOffset(),
};
var new_node = focus_node;
var new_offset = focus_offset;
if (isTextNode(focus_node)) {
if (forward) {
const len = focus_node.getLength();
if (focus_offset < len) {
new_offset += 1;
} else if (nextTextNode(focus_node)) |next| {
new_node = next;
new_offset = 0;
}
} else {
if (focus_offset > 0) {
new_offset -= 1;
} else if (prevTextNode(focus_node)) |prev| {
new_node = prev;
new_offset = prev.getLength();
}
}
} else {
if (forward) {
if (focus_node.getChildAt(focus_offset)) |child| {
if (isTextNode(child)) {
new_node = child;
new_offset = 0;
} else if (nextTextNode(child)) |t| {
new_node = t;
new_offset = 0;
}
} else if (nextTextNodeAfter(focus_node)) |next| {
new_node = next;
new_offset = 1;
}
} else {
// backward element-node case
var idx = focus_offset;
while (idx > 0) {
idx -= 1;
const child = focus_node.getChildAt(idx) orelse break;
var bottom = child;
while (bottom.lastChild()) |c| bottom = c;
if (isTextNode(bottom)) {
new_node = bottom;
new_offset = bottom.getLength();
break;
}
}
}
}
try self.applyModify(alter, new_node, new_offset, page);
}
fn isWordChar(c: u8) bool {
return std.ascii.isAlphanumeric(c) or c == '_';
}
fn nextWordEnd(text: []const u8, offset: u32) u32 {
var i = offset;
// consumes whitespace till next word
while (i < text.len and !isWordChar(text[i])) : (i += 1) {}
// consumes next word
while (i < text.len and isWordChar(text[i])) : (i += 1) {}
return i;
}
fn prevWordStart(text: []const u8, offset: u32) u32 {
var i = offset;
if (i > 0) i -= 1;
// consumes the white space
while (i > 0 and !isWordChar(text[i])) : (i -= 1) {}
// consumes the last word
while (i > 0 and isWordChar(text[i - 1])) : (i -= 1) {}
return i;
}
fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Range, page: *Page) !void {
const abstract = range.asAbstractRange();
const focus_node = switch (self._direction) {
.backward => abstract.getStartContainer(),
.forward, .none => abstract.getEndContainer(),
};
const focus_offset = switch (self._direction) {
.backward => abstract.getStartOffset(),
.forward, .none => abstract.getEndOffset(),
};
var new_node = focus_node;
var new_offset = focus_offset;
if (isTextNode(focus_node)) {
if (forward) {
const i = nextWordEnd(new_node.getData().str(), new_offset);
if (i > new_offset) {
new_offset = i;
} else if (nextTextNode(focus_node)) |next| {
new_node = next;
new_offset = nextWordEnd(next.getData().str(), 0);
}
} else {
const i = prevWordStart(new_node.getData().str(), new_offset);
if (i < new_offset) {
new_offset = i;
} else if (prevTextNode(focus_node)) |prev| {
new_node = prev;
new_offset = prevWordStart(prev.getData().str(), @intCast(prev.getData().len));
}
}
} else {
// Search and apply rules on the next Text Node.
// This is either next (on forward) or previous (on backward).
if (forward) {
const child = focus_node.getChildAt(focus_offset) orelse {
if (nextTextNodeAfter(focus_node)) |next| {
new_node = next;
new_offset = nextWordEnd(next.getData().str(), 0);
}
return self.applyModify(alter, new_node, new_offset, page);
};
const t = if (isTextNode(child)) child else nextTextNode(child) orelse {
return self.applyModify(alter, new_node, new_offset, page);
};
new_node = t;
new_offset = nextWordEnd(t.getData().str(), 0);
} else {
var idx = focus_offset;
while (idx > 0) {
idx -= 1;
const child = focus_node.getChildAt(idx) orelse break;
var bottom = child;
while (bottom.lastChild()) |c| bottom = c;
if (isTextNode(bottom)) {
new_node = bottom;
new_offset = prevWordStart(bottom.getData().str(), bottom.getLength());
break;
}
}
}
}
try self.applyModify(alter, new_node, new_offset, page);
}
fn applyModify(self: *Selection, alter: ModifyAlter, new_node: *Node, new_offset: u32, page: *Page) !void {
switch (alter) {
.move => {
const new_range = try Range.init(page);
try new_range.setStart(new_node, new_offset);
try new_range.setEnd(new_node, new_offset);
self._range = new_range;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
},
.extend => try self.extend(new_node, new_offset, page),
}
} }
pub fn selectAllChildren(self: *Selection, parent: *Node, page: *Page) !void { pub fn selectAllChildren(self: *Selection, parent: *Node, page: *Page) !void {

View File

@@ -308,12 +308,10 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void {
.cancelable = true, .cancelable = true,
}, page); }, page);
const event = error_event.asEvent();
defer if (!event._v8_handoff) event.deinit(false);
// Invoke window.onerror callback if set (per WHATWG spec, this is called // Invoke window.onerror callback if set (per WHATWG spec, this is called
// with 5 arguments: message, source, lineno, colno, error) // with 5 arguments: message, source, lineno, colno, error)
// If it returns true, the event is cancelled. // If it returns true, the event is cancelled.
var prevent_default = false;
if (self._on_error) |on_error| { if (self._on_error) |on_error| {
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
page.js.localScope(&ls); page.js.localScope(&ls);
@@ -330,12 +328,12 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void {
// Per spec: returning true from onerror cancels the event // Per spec: returning true from onerror cancels the event
if (result) |r| { if (result) |r| {
if (r.isTrue()) { prevent_default = r.isTrue();
event._prevent_default = true;
}
} }
} }
const event = error_event.asEvent();
event._prevent_default = prevent_default;
try page._event_manager.dispatch(self.asEventTarget(), event); try page._event_manager.dispatch(self.asEventTarget(), event);
if (comptime builtin.is_test == false) { if (comptime builtin.is_test == false) {
@@ -398,9 +396,19 @@ pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace); const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace);
const decoded_len = std.base64.standard.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError; // Forgiving base64 decode per WHATWG spec:
// https://infra.spec.whatwg.org/#forgiving-base64-decode
// Remove trailing padding to use standard_no_pad decoder
const unpadded = std.mem.trimRight(u8, trimmed, "=");
// Length % 4 == 1 is invalid (can't represent valid base64)
if (unpadded.len % 4 == 1) {
return error.InvalidCharacterError;
}
const decoded_len = std.base64.standard_no_pad.Decoder.calcSizeForSlice(unpadded) catch return error.InvalidCharacterError;
const decoded = try page.call_arena.alloc(u8, decoded_len); const decoded = try page.call_arena.alloc(u8, decoded_len);
std.base64.standard.Decoder.decode(decoded, trimmed) catch return error.InvalidCharacterError; std.base64.standard_no_pad.Decoder.decode(decoded, unpadded) catch return error.InvalidCharacterError;
return decoded; return decoded;
} }
@@ -478,7 +486,6 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
} }
const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, p); const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, p);
defer if (!event._v8_handoff) event.deinit(false);
try p._event_manager.dispatch(p.document.asEventTarget(), event); try p._event_manager.dispatch(p.document.asEventTarget(), event);
pos.state = .end; pos.state = .end;
@@ -506,7 +513,6 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
.done => return null, .done => return null,
} }
const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, p); const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, p);
defer if (!event._v8_handoff) event.deinit(false);
try p._event_manager.dispatch(p.document.asEventTarget(), event); try p._event_manager.dispatch(p.document.asEventTarget(), event);
pos.state = .done; pos.state = .done;
@@ -519,6 +525,24 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
); );
} }
pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
// The scroll is relative to the current position. So compute to new
// absolute position.
var absx: i32 = undefined;
var absy: i32 = undefined;
switch (opts) {
.x => |x| {
absx = @as(i32, @intCast(self._scroll_pos.x)) + x;
absy = @as(i32, @intCast(self._scroll_pos.y)) + (y orelse 0);
},
.opts => |o| {
absx = @as(i32, @intCast(self._scroll_pos.x)) + o.left;
absy = @as(i32, @intCast(self._scroll_pos.y)) + o.top;
},
}
return self.scrollTo(.{ .x = absx }, absy, page);
}
pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection, page: *Page) !void { pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection, page: *Page) !void {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.js, "unhandled rejection", .{ log.debug(.js, "unhandled rejection", .{
@@ -527,11 +551,10 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection,
}); });
} }
var event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{ const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
.reason = if (rejection.reason()) |r| try r.temp() else null, .reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(), .promise = try rejection.promise().temp(),
}, page)).asEvent(); }, page)).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatchWithFunction( try page._event_manager.dispatchWithFunction(
self.asEventTarget(), self.asEventTarget(),
@@ -705,7 +728,6 @@ const PostMessageCallback = struct {
.bubbles = false, .bubbles = false,
.cancelable = false, .cancelable = false,
}, page)).asEvent(); }, page)).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatch(window.asEventTarget(), event); try page._event_manager.dispatch(window.asEventTarget(), event);
return null; return null;
@@ -782,7 +804,7 @@ pub const JsApi = struct {
pub const getSelection = bridge.function(Window.getSelection, .{}); pub const getSelection = bridge.function(Window.getSelection, .{});
pub const frames = bridge.accessor(Window.getWindow, null, .{}); pub const frames = bridge.accessor(Window.getWindow, null, .{});
pub const index = bridge.indexed(Window.getFrame, .{ .null_as_undefined = true }); pub const index = bridge.indexed(Window.getFrame, null, .{ .null_as_undefined = true });
pub const length = bridge.accessor(Window.getFramesLength, null, .{}); pub const length = bridge.accessor(Window.getFramesLength, null, .{});
pub const scrollX = bridge.accessor(Window.getScrollX, null, .{}); pub const scrollX = bridge.accessor(Window.getScrollX, null, .{});
pub const scrollY = bridge.accessor(Window.getScrollY, null, .{}); pub const scrollY = bridge.accessor(Window.getScrollY, null, .{});
@@ -790,6 +812,7 @@ pub const JsApi = struct {
pub const pageYOffset = bridge.accessor(Window.getScrollY, null, .{}); pub const pageYOffset = bridge.accessor(Window.getScrollY, null, .{});
pub const scrollTo = bridge.function(Window.scrollTo, .{}); pub const scrollTo = bridge.function(Window.scrollTo, .{});
pub const scroll = bridge.function(Window.scrollTo, .{}); pub const scroll = bridge.function(Window.scrollTo, .{});
pub const scrollBy = bridge.function(Window.scrollBy, .{});
// Return false since we don't have secure-context-only APIs implemented // Return false since we don't have secure-context-only APIs implemented
// (webcam, geolocation, clipboard, etc.) // (webcam, geolocation, clipboard, etc.)
@@ -807,7 +830,7 @@ pub const JsApi = struct {
pub const alert = bridge.function(struct { pub const alert = bridge.function(struct {
fn alert(_: *const Window, _: ?[]const u8) void {} fn alert(_: *const Window, _: ?[]const u8) void {}
}.alert, .{}); }.alert, .{ .noop = true });
pub const confirm = bridge.function(struct { pub const confirm = bridge.function(struct {
fn confirm(_: *const Window, _: ?[]const u8) bool { fn confirm(_: *const Window, _: ?[]const u8) bool {
return false; return false;
@@ -824,3 +847,7 @@ const testing = @import("../../testing.zig");
test "WebApi: Window" { test "WebApi: Window" {
try testing.htmlRunner("window", .{}); try testing.htmlRunner("window", .{});
} }
test "WebApi: Window scroll" {
try testing.htmlRunner("window_scroll.html", .{});
}

View File

@@ -25,6 +25,9 @@ const dump = @import("../dump.zig");
const XMLSerializer = @This(); const XMLSerializer = @This();
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
pub fn init() XMLSerializer { pub fn init() XMLSerializer {
return .{}; return .{};
} }

View File

@@ -61,8 +61,8 @@ pub fn init(page: *Page) !*Animation {
return self; return self;
} }
pub fn deinit(self: *Animation, _: bool) void { pub fn deinit(self: *Animation, _: bool, page: *Page) void {
self._page.releaseArena(self._arena); page.releaseArena(self._arena);
} }
pub fn play(self: *Animation, page: *Page) !void { pub fn play(self: *Animation, page: *Page) !void {

View File

@@ -47,46 +47,6 @@ pub fn setFillStyle(
self._fill_style = color.RGBA.parse(value) catch self._fill_style; self._fill_style = color.RGBA.parse(value) catch self._fill_style;
} }
pub fn getGlobalAlpha(_: *const CanvasRenderingContext2D) f64 {
return 1.0;
}
pub fn getGlobalCompositeOperation(_: *const CanvasRenderingContext2D) []const u8 {
return "source-over";
}
pub fn getStrokeStyle(_: *const CanvasRenderingContext2D) []const u8 {
return "#000000";
}
pub fn getLineWidth(_: *const CanvasRenderingContext2D) f64 {
return 1.0;
}
pub fn getLineCap(_: *const CanvasRenderingContext2D) []const u8 {
return "butt";
}
pub fn getLineJoin(_: *const CanvasRenderingContext2D) []const u8 {
return "miter";
}
pub fn getMiterLimit(_: *const CanvasRenderingContext2D) f64 {
return 10.0;
}
pub fn getFont(_: *const CanvasRenderingContext2D) []const u8 {
return "10px sans-serif";
}
pub fn getTextAlign(_: *const CanvasRenderingContext2D) []const u8 {
return "start";
}
pub fn getTextBaseline(_: *const CanvasRenderingContext2D) []const u8 {
return "alphabetic";
}
const WidthOrImageData = union(enum) { const WidthOrImageData = union(enum) {
width: u32, width: u32,
image_data: *ImageData, image_data: *ImageData,
@@ -113,7 +73,6 @@ pub fn createImageData(
} }
pub fn putImageData(_: *const CanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {} pub fn putImageData(_: *const CanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {}
pub fn save(_: *CanvasRenderingContext2D) void {} pub fn save(_: *CanvasRenderingContext2D) void {}
pub fn restore(_: *CanvasRenderingContext2D) void {} pub fn restore(_: *CanvasRenderingContext2D) void {}
pub fn scale(_: *CanvasRenderingContext2D, _: f64, _: f64) void {} pub fn scale(_: *CanvasRenderingContext2D, _: f64, _: f64) void {}
@@ -122,13 +81,7 @@ pub fn translate(_: *CanvasRenderingContext2D, _: f64, _: f64) void {}
pub fn transform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn transform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {}
pub fn setTransform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn setTransform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {}
pub fn resetTransform(_: *CanvasRenderingContext2D) void {} pub fn resetTransform(_: *CanvasRenderingContext2D) void {}
pub fn setGlobalAlpha(_: *CanvasRenderingContext2D, _: f64) void {}
pub fn setGlobalCompositeOperation(_: *CanvasRenderingContext2D, _: []const u8) void {}
pub fn setStrokeStyle(_: *CanvasRenderingContext2D, _: []const u8) void {} pub fn setStrokeStyle(_: *CanvasRenderingContext2D, _: []const u8) void {}
pub fn setLineWidth(_: *CanvasRenderingContext2D, _: f64) void {}
pub fn setLineCap(_: *CanvasRenderingContext2D, _: []const u8) void {}
pub fn setLineJoin(_: *CanvasRenderingContext2D, _: []const u8) void {}
pub fn setMiterLimit(_: *CanvasRenderingContext2D, _: f64) void {}
pub fn clearRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn clearRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
pub fn fillRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn fillRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
pub fn strokeRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn strokeRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
@@ -144,9 +97,6 @@ pub fn rect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {
pub fn fill(_: *CanvasRenderingContext2D) void {} pub fn fill(_: *CanvasRenderingContext2D) void {}
pub fn stroke(_: *CanvasRenderingContext2D) void {} pub fn stroke(_: *CanvasRenderingContext2D) void {}
pub fn clip(_: *CanvasRenderingContext2D) void {} pub fn clip(_: *CanvasRenderingContext2D) void {}
pub fn setFont(_: *CanvasRenderingContext2D, _: []const u8) void {}
pub fn setTextAlign(_: *CanvasRenderingContext2D, _: []const u8) void {}
pub fn setTextBaseline(_: *CanvasRenderingContext2D, _: []const u8) void {}
pub fn fillText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} pub fn fillText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {}
pub fn strokeText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} pub fn strokeText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {}
@@ -160,53 +110,46 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
}; };
pub const createImageData = bridge.function(CanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false });
pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{}); pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false });
pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false });
pub const save = bridge.function(CanvasRenderingContext2D.save, .{}); pub const strokeStyle = bridge.property("#000000", .{ .template = false, .readonly = false });
pub const restore = bridge.function(CanvasRenderingContext2D.restore, .{}); pub const lineWidth = bridge.property(1.0, .{ .template = false, .readonly = false });
pub const lineCap = bridge.property("butt", .{ .template = false, .readonly = false });
pub const scale = bridge.function(CanvasRenderingContext2D.scale, .{}); pub const lineJoin = bridge.property("miter", .{ .template = false, .readonly = false });
pub const rotate = bridge.function(CanvasRenderingContext2D.rotate, .{}); pub const miterLimit = bridge.property(10.0, .{ .template = false, .readonly = false });
pub const translate = bridge.function(CanvasRenderingContext2D.translate, .{}); pub const textAlign = bridge.property("start", .{ .template = false, .readonly = false });
pub const transform = bridge.function(CanvasRenderingContext2D.transform, .{}); pub const textBaseline = bridge.property("alphabetic", .{ .template = false, .readonly = false });
pub const setTransform = bridge.function(CanvasRenderingContext2D.setTransform, .{});
pub const resetTransform = bridge.function(CanvasRenderingContext2D.resetTransform, .{});
pub const globalAlpha = bridge.accessor(CanvasRenderingContext2D.getGlobalAlpha, CanvasRenderingContext2D.setGlobalAlpha, .{});
pub const globalCompositeOperation = bridge.accessor(CanvasRenderingContext2D.getGlobalCompositeOperation, CanvasRenderingContext2D.setGlobalCompositeOperation, .{});
pub const fillStyle = bridge.accessor(CanvasRenderingContext2D.getFillStyle, CanvasRenderingContext2D.setFillStyle, .{}); pub const fillStyle = bridge.accessor(CanvasRenderingContext2D.getFillStyle, CanvasRenderingContext2D.setFillStyle, .{});
pub const strokeStyle = bridge.accessor(CanvasRenderingContext2D.getStrokeStyle, CanvasRenderingContext2D.setStrokeStyle, .{}); pub const createImageData = bridge.function(CanvasRenderingContext2D.createImageData, .{ .dom_exception = true });
pub const lineWidth = bridge.accessor(CanvasRenderingContext2D.getLineWidth, CanvasRenderingContext2D.setLineWidth, .{}); pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{ .noop = true });
pub const lineCap = bridge.accessor(CanvasRenderingContext2D.getLineCap, CanvasRenderingContext2D.setLineCap, .{}); pub const save = bridge.function(CanvasRenderingContext2D.save, .{ .noop = true });
pub const lineJoin = bridge.accessor(CanvasRenderingContext2D.getLineJoin, CanvasRenderingContext2D.setLineJoin, .{}); pub const restore = bridge.function(CanvasRenderingContext2D.restore, .{ .noop = true });
pub const miterLimit = bridge.accessor(CanvasRenderingContext2D.getMiterLimit, CanvasRenderingContext2D.setMiterLimit, .{}); pub const scale = bridge.function(CanvasRenderingContext2D.scale, .{ .noop = true });
pub const rotate = bridge.function(CanvasRenderingContext2D.rotate, .{ .noop = true });
pub const clearRect = bridge.function(CanvasRenderingContext2D.clearRect, .{}); pub const translate = bridge.function(CanvasRenderingContext2D.translate, .{ .noop = true });
pub const fillRect = bridge.function(CanvasRenderingContext2D.fillRect, .{}); pub const transform = bridge.function(CanvasRenderingContext2D.transform, .{ .noop = true });
pub const strokeRect = bridge.function(CanvasRenderingContext2D.strokeRect, .{}); pub const setTransform = bridge.function(CanvasRenderingContext2D.setTransform, .{ .noop = true });
pub const resetTransform = bridge.function(CanvasRenderingContext2D.resetTransform, .{ .noop = true });
pub const beginPath = bridge.function(CanvasRenderingContext2D.beginPath, .{}); pub const clearRect = bridge.function(CanvasRenderingContext2D.clearRect, .{ .noop = true });
pub const closePath = bridge.function(CanvasRenderingContext2D.closePath, .{}); pub const fillRect = bridge.function(CanvasRenderingContext2D.fillRect, .{ .noop = true });
pub const moveTo = bridge.function(CanvasRenderingContext2D.moveTo, .{}); pub const strokeRect = bridge.function(CanvasRenderingContext2D.strokeRect, .{ .noop = true });
pub const lineTo = bridge.function(CanvasRenderingContext2D.lineTo, .{}); pub const beginPath = bridge.function(CanvasRenderingContext2D.beginPath, .{ .noop = true });
pub const quadraticCurveTo = bridge.function(CanvasRenderingContext2D.quadraticCurveTo, .{}); pub const closePath = bridge.function(CanvasRenderingContext2D.closePath, .{ .noop = true });
pub const bezierCurveTo = bridge.function(CanvasRenderingContext2D.bezierCurveTo, .{}); pub const moveTo = bridge.function(CanvasRenderingContext2D.moveTo, .{ .noop = true });
pub const arc = bridge.function(CanvasRenderingContext2D.arc, .{}); pub const lineTo = bridge.function(CanvasRenderingContext2D.lineTo, .{ .noop = true });
pub const arcTo = bridge.function(CanvasRenderingContext2D.arcTo, .{}); pub const quadraticCurveTo = bridge.function(CanvasRenderingContext2D.quadraticCurveTo, .{ .noop = true });
pub const rect = bridge.function(CanvasRenderingContext2D.rect, .{}); pub const bezierCurveTo = bridge.function(CanvasRenderingContext2D.bezierCurveTo, .{ .noop = true });
pub const arc = bridge.function(CanvasRenderingContext2D.arc, .{ .noop = true });
pub const fill = bridge.function(CanvasRenderingContext2D.fill, .{}); pub const arcTo = bridge.function(CanvasRenderingContext2D.arcTo, .{ .noop = true });
pub const stroke = bridge.function(CanvasRenderingContext2D.stroke, .{}); pub const rect = bridge.function(CanvasRenderingContext2D.rect, .{ .noop = true });
pub const clip = bridge.function(CanvasRenderingContext2D.clip, .{}); pub const fill = bridge.function(CanvasRenderingContext2D.fill, .{ .noop = true });
pub const stroke = bridge.function(CanvasRenderingContext2D.stroke, .{ .noop = true });
pub const font = bridge.accessor(CanvasRenderingContext2D.getFont, CanvasRenderingContext2D.setFont, .{}); pub const clip = bridge.function(CanvasRenderingContext2D.clip, .{ .noop = true });
pub const textAlign = bridge.accessor(CanvasRenderingContext2D.getTextAlign, CanvasRenderingContext2D.setTextAlign, .{}); pub const fillText = bridge.function(CanvasRenderingContext2D.fillText, .{ .noop = true });
pub const textBaseline = bridge.accessor(CanvasRenderingContext2D.getTextBaseline, CanvasRenderingContext2D.setTextBaseline, .{}); pub const strokeText = bridge.function(CanvasRenderingContext2D.strokeText, .{ .noop = true });
pub const fillText = bridge.function(CanvasRenderingContext2D.fillText, .{});
pub const strokeText = bridge.function(CanvasRenderingContext2D.strokeText, .{});
}; };
const testing = @import("../../../testing.zig"); const testing = @import("../../../testing.zig");

View File

@@ -0,0 +1,105 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Blob = @import("../Blob.zig");
const OffscreenCanvasRenderingContext2D = @import("OffscreenCanvasRenderingContext2D.zig");
/// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
const OffscreenCanvas = @This();
pub const _prototype_root = true;
_width: u32,
_height: u32,
/// Since there's no base class rendering contextes inherit from,
/// we're using tagged union.
const DrawingContext = union(enum) {
@"2d": *OffscreenCanvasRenderingContext2D,
};
pub fn constructor(width: u32, height: u32, page: *Page) !*OffscreenCanvas {
return page._factory.create(OffscreenCanvas{
._width = width,
._height = height,
});
}
pub fn getWidth(self: *const OffscreenCanvas) u32 {
return self._width;
}
pub fn setWidth(self: *OffscreenCanvas, value: u32) void {
self._width = value;
}
pub fn getHeight(self: *const OffscreenCanvas) u32 {
return self._height;
}
pub fn setHeight(self: *OffscreenCanvas, value: u32) void {
self._height = value;
}
pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, page: *Page) !?DrawingContext {
if (std.mem.eql(u8, context_type, "2d")) {
const ctx = try page._factory.create(OffscreenCanvasRenderingContext2D{});
return .{ .@"2d" = ctx };
}
return null;
}
/// Returns a Promise that resolves to a Blob containing the image.
/// Since we have no actual rendering, this returns an empty blob.
pub fn convertToBlob(_: *OffscreenCanvas, page: *Page) !js.Promise {
const blob = try Blob.init(null, null, page);
return page.js.local.?.resolvePromise(blob);
}
/// Returns an ImageBitmap with the rendered content (stub).
pub fn transferToImageBitmap(_: *OffscreenCanvas) ?void {
// ImageBitmap not implemented yet, return null
return null;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(OffscreenCanvas);
pub const Meta = struct {
pub const name = "OffscreenCanvas";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(OffscreenCanvas.constructor, .{});
pub const width = bridge.accessor(OffscreenCanvas.getWidth, OffscreenCanvas.setWidth, .{});
pub const height = bridge.accessor(OffscreenCanvas.getHeight, OffscreenCanvas.setHeight, .{});
pub const getContext = bridge.function(OffscreenCanvas.getContext, .{});
pub const convertToBlob = bridge.function(OffscreenCanvas.convertToBlob, .{});
pub const transferToImageBitmap = bridge.function(OffscreenCanvas.transferToImageBitmap, .{});
};
const testing = @import("../../../testing.zig");
test "WebApi: OffscreenCanvas" {
try testing.htmlRunner("canvas/offscreen_canvas.html", .{});
}

View File

@@ -0,0 +1,152 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../../js/js.zig");
const color = @import("../../color.zig");
const Page = @import("../../Page.zig");
const ImageData = @import("../ImageData.zig");
/// This class doesn't implement a `constructor`.
/// It can be obtained with a call to `OffscreenCanvas#getContext`.
/// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvasRenderingContext2D
const OffscreenCanvasRenderingContext2D = @This();
/// Fill color.
/// TODO: Add support for `CanvasGradient` and `CanvasPattern`.
_fill_style: color.RGBA = color.RGBA.Named.black,
pub fn getFillStyle(self: *const OffscreenCanvasRenderingContext2D, page: *Page) ![]const u8 {
var w = std.Io.Writer.Allocating.init(page.call_arena);
try self._fill_style.format(&w.writer);
return w.written();
}
pub fn setFillStyle(
self: *OffscreenCanvasRenderingContext2D,
value: []const u8,
) !void {
// Prefer the same fill_style if fails.
self._fill_style = color.RGBA.parse(value) catch self._fill_style;
}
const WidthOrImageData = union(enum) {
width: u32,
image_data: *ImageData,
};
pub fn createImageData(
_: *const OffscreenCanvasRenderingContext2D,
width_or_image_data: WidthOrImageData,
/// If `ImageData` variant preferred, this is null.
maybe_height: ?u32,
/// Can be used if width and height provided.
maybe_settings: ?ImageData.ConstructorSettings,
page: *Page,
) !*ImageData {
switch (width_or_image_data) {
.width => |width| {
const height = maybe_height orelse return error.TypeError;
return ImageData.constructor(width, height, maybe_settings, page);
},
.image_data => |image_data| {
return ImageData.constructor(image_data._width, image_data._height, null, page);
},
}
}
pub fn putImageData(_: *const OffscreenCanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {}
pub fn save(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn restore(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn scale(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {}
pub fn rotate(_: *OffscreenCanvasRenderingContext2D, _: f64) void {}
pub fn translate(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {}
pub fn transform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {}
pub fn setTransform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {}
pub fn resetTransform(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn setStrokeStyle(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {}
pub fn clearRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
pub fn fillRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
pub fn strokeRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
pub fn beginPath(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn closePath(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn moveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {}
pub fn lineTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {}
pub fn quadraticCurveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
pub fn bezierCurveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {}
pub fn arc(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: ?bool) void {}
pub fn arcTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64) void {}
pub fn rect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {}
pub fn fill(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn stroke(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn clip(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn fillText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {}
pub fn strokeText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {}
pub const JsApi = struct {
pub const bridge = js.Bridge(OffscreenCanvasRenderingContext2D);
pub const Meta = struct {
pub const name = "OffscreenCanvasRenderingContext2D";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false });
pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false });
pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false });
pub const strokeStyle = bridge.property("#000000", .{ .template = false, .readonly = false });
pub const lineWidth = bridge.property(1.0, .{ .template = false, .readonly = false });
pub const lineCap = bridge.property("butt", .{ .template = false, .readonly = false });
pub const lineJoin = bridge.property("miter", .{ .template = false, .readonly = false });
pub const miterLimit = bridge.property(10.0, .{ .template = false, .readonly = false });
pub const textAlign = bridge.property("start", .{ .template = false, .readonly = false });
pub const textBaseline = bridge.property("alphabetic", .{ .template = false, .readonly = false });
pub const fillStyle = bridge.accessor(OffscreenCanvasRenderingContext2D.getFillStyle, OffscreenCanvasRenderingContext2D.setFillStyle, .{});
pub const createImageData = bridge.function(OffscreenCanvasRenderingContext2D.createImageData, .{ .dom_exception = true });
pub const putImageData = bridge.function(OffscreenCanvasRenderingContext2D.putImageData, .{ .noop = true });
pub const save = bridge.function(OffscreenCanvasRenderingContext2D.save, .{ .noop = true });
pub const restore = bridge.function(OffscreenCanvasRenderingContext2D.restore, .{ .noop = true });
pub const scale = bridge.function(OffscreenCanvasRenderingContext2D.scale, .{ .noop = true });
pub const rotate = bridge.function(OffscreenCanvasRenderingContext2D.rotate, .{ .noop = true });
pub const translate = bridge.function(OffscreenCanvasRenderingContext2D.translate, .{ .noop = true });
pub const transform = bridge.function(OffscreenCanvasRenderingContext2D.transform, .{ .noop = true });
pub const setTransform = bridge.function(OffscreenCanvasRenderingContext2D.setTransform, .{ .noop = true });
pub const resetTransform = bridge.function(OffscreenCanvasRenderingContext2D.resetTransform, .{ .noop = true });
pub const clearRect = bridge.function(OffscreenCanvasRenderingContext2D.clearRect, .{ .noop = true });
pub const fillRect = bridge.function(OffscreenCanvasRenderingContext2D.fillRect, .{ .noop = true });
pub const strokeRect = bridge.function(OffscreenCanvasRenderingContext2D.strokeRect, .{ .noop = true });
pub const beginPath = bridge.function(OffscreenCanvasRenderingContext2D.beginPath, .{ .noop = true });
pub const closePath = bridge.function(OffscreenCanvasRenderingContext2D.closePath, .{ .noop = true });
pub const moveTo = bridge.function(OffscreenCanvasRenderingContext2D.moveTo, .{ .noop = true });
pub const lineTo = bridge.function(OffscreenCanvasRenderingContext2D.lineTo, .{ .noop = true });
pub const quadraticCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.quadraticCurveTo, .{ .noop = true });
pub const bezierCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.bezierCurveTo, .{ .noop = true });
pub const arc = bridge.function(OffscreenCanvasRenderingContext2D.arc, .{ .noop = true });
pub const arcTo = bridge.function(OffscreenCanvasRenderingContext2D.arcTo, .{ .noop = true });
pub const rect = bridge.function(OffscreenCanvasRenderingContext2D.rect, .{ .noop = true });
pub const fill = bridge.function(OffscreenCanvasRenderingContext2D.fill, .{ .noop = true });
pub const stroke = bridge.function(OffscreenCanvasRenderingContext2D.stroke, .{ .noop = true });
pub const clip = bridge.function(OffscreenCanvasRenderingContext2D.clip, .{ .noop = true });
pub const fillText = bridge.function(OffscreenCanvasRenderingContext2D.fillText, .{ .noop = true });
pub const strokeText = bridge.function(OffscreenCanvasRenderingContext2D.strokeText, .{ .noop = true });
};

View File

@@ -122,14 +122,6 @@ pub const Extension = union(enum) {
pub const UNMASKED_VENDOR_WEBGL: u64 = 0x9245; pub const UNMASKED_VENDOR_WEBGL: u64 = 0x9245;
pub const UNMASKED_RENDERER_WEBGL: u64 = 0x9246; pub const UNMASKED_RENDERER_WEBGL: u64 = 0x9246;
pub fn getUnmaskedVendorWebGL(_: *const WEBGL_debug_renderer_info) u64 {
return UNMASKED_VENDOR_WEBGL;
}
pub fn getUnmaskedRendererWebGL(_: *const WEBGL_debug_renderer_info) u64 {
return UNMASKED_RENDERER_WEBGL;
}
pub const JsApi = struct { pub const JsApi = struct {
pub const bridge = js.Bridge(WEBGL_debug_renderer_info); pub const bridge = js.Bridge(WEBGL_debug_renderer_info);
@@ -140,8 +132,8 @@ pub const Extension = union(enum) {
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
}; };
pub const UNMASKED_VENDOR_WEBGL = bridge.accessor(WEBGL_debug_renderer_info.getUnmaskedVendorWebGL, null, .{}); pub const UNMASKED_VENDOR_WEBGL = bridge.property(WEBGL_debug_renderer_info.UNMASKED_VENDOR_WEBGL, .{ .template = false, .readonly = true });
pub const UNMASKED_RENDERER_WEBGL = bridge.accessor(WEBGL_debug_renderer_info.getUnmaskedRendererWebGL, null, .{}); pub const UNMASKED_RENDERER_WEBGL = bridge.property(WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL, .{ .template = false, .readonly = true });
}; };
}; };
@@ -160,8 +152,8 @@ pub const Extension = union(enum) {
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
}; };
pub const loseContext = bridge.function(WEBGL_lose_context.loseContext, .{}); pub const loseContext = bridge.function(WEBGL_lose_context.loseContext, .{ .noop = true });
pub const restoreContext = bridge.function(WEBGL_lose_context.restoreContext, .{}); pub const restoreContext = bridge.function(WEBGL_lose_context.restoreContext, .{ .noop = true });
}; };
}; };
}; };

View File

@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const String = @import("../../../string.zig").String;
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const CData = @import("../CData.zig"); const CData = @import("../CData.zig");
@@ -30,11 +31,11 @@ pub fn init(str: ?js.NullableString, page: *Page) !*Text {
} }
pub fn getWholeText(self: *Text) []const u8 { pub fn getWholeText(self: *Text) []const u8 {
return self._proto._data; return self._proto._data.str();
} }
pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text { pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text {
const data = self._proto._data; const data = self._proto._data.str();
const byte_offset = CData.utf16OffsetToUtf8(data, offset) catch return error.IndexSizeError; const byte_offset = CData.utf16OffsetToUtf8(data, offset) catch return error.IndexSizeError;

Some files were not shown because too many files have changed in this diff Show More