128 Commits

Author SHA1 Message Date
Pierre Tachoire
a2f20d572d wip zig-v8 2025-07-05 14:19:55 -07:00
Pierre Tachoire
72499390d8 Use isolate form snapshot_creator 2025-07-05 14:19:55 -07:00
Pierre Tachoire
f51ee7f3a0 Merge pull request #829 from lightpanda-io/pumpmessageloop
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add pump message loop calls
2025-07-03 10:11:26 -07:00
Pierre Tachoire
9d1dc97766 remove useless debug log 2025-07-03 09:49:01 -07:00
Pierre Tachoire
b78729f685 test: inject platform to the serveCDP app 2025-07-03 09:49:00 -07:00
Pierre Tachoire
44a76e59f9 run pumpmessageloop in its own loop 2025-07-03 09:49:00 -07:00
Pierre Tachoire
1504e36a68 use comptime test for platform existence 2025-07-03 09:49:00 -07:00
Pierre Tachoire
80348ef190 fix wpt tests with platform requirement 2025-07-03 09:48:59 -07:00
Pierre Tachoire
a3c14748d3 fix unit testing with platform deps requirement 2025-07-03 09:48:59 -07:00
Pierre Tachoire
3c0143af92 add runIdleTasks 2025-07-03 09:48:57 -07:00
Pierre Tachoire
22a93a9c39 add pump message loop calls 2025-07-03 09:47:50 -07:00
Karl Seguin
e8866a6431 Merge pull request #838 from lightpanda-io/improved_js_value_printing
Improve JS value printing
2025-07-04 00:30:35 +08:00
Karl Seguin
455ed79872 Remove HTTP client generic Loop parameter
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
I think we initially thought we might need different clients for different
parts of the system, each with a unique loop  (e.g. we thought telemetry might
need some isolation). But that never happened, so it's just needless now,
especially since the async connect uses the non-generic *Loop type directly.
2025-07-03 15:10:47 +02:00
Karl Seguin
bf1db50667 Merge pull request #839 from lightpanda-io/build_time
improve build times (a little)
2025-07-03 15:34:53 +08:00
Pierre Tachoire
a2565a7c83 range: add detach function 2025-07-03 09:16:36 +02:00
Pierre Tachoire
947d01a3c0 range starts and ends with the global document by default 2025-07-03 09:16:36 +02:00
Karl Seguin
be11d82c9c improve build times (a little) 2025-07-03 13:56:01 +08:00
Karl Seguin
7a0e7fff13 Improve JS value printing
Don't error on JSON.stringify failure (likely caused by circular reference).

In debug mode, try to print [slightly] more meaningful value representation
when default serialization results in [object Object].
2025-07-03 10:35:09 +08:00
Karl Seguin
81fb71b7f7 Merge pull request #830 from lightpanda-io/SetHostInitializeImportMetaObjectCallback
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Implement ImportMeta callback
2025-07-03 09:17:47 +08:00
Karl Seguin
b10f5ec99f bump zig-v8-fork version 2025-07-02 13:38:01 +08:00
Karl Seguin
5abe7bdeef Merge pull request #831 from lightpanda-io/log_invalid_cookie_expiry
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
use logger for invalid cookie expiry
2025-07-02 10:11:20 +08:00
Karl Seguin
54be651415 Merge pull request #832 from lightpanda-io/range_selectNodeContents
range.selectNodeContents
2025-07-02 10:11:01 +08:00
Pierre Tachoire
cdbf6d7ae7 Merge pull request #834 from lightpanda-io/arm-generic
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
ARM generic
2025-07-01 12:08:30 -07:00
Pierre Tachoire
50349edf4d ci: use a generic target for arm build 2025-07-01 07:55:32 -07:00
sjorsdonkers
da307c1b40 range.selectNodeContents 2025-07-01 15:11:01 +02:00
Karl Seguin
b50b96bd1d Implement ImportMeta callback
The first time `import.meta` is called within a module, this callback is called
and we can populate it with whatever fields we want. For WebAPI, the important
field is `url`:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta

Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/80
2025-07-01 15:59:24 +08:00
Karl Seguin
92654fc5aa use logger for invalid cookie expiry 2025-07-01 12:25:25 +08:00
Pierre Tachoire
36b2de216b Merge pull request #828 from lightpanda-io/arm-compat
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Arm compat
2025-06-30 11:53:06 -07:00
Pierre Tachoire
8745c1016e ci: use cpu cortex_a72 for arm build 2025-06-30 10:58:15 -07:00
Pierre Tachoire
f5a58c1ff0 Merge pull request #826 from lightpanda-io/upgrade-v8
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Upgrade v8
2025-06-29 10:54:46 -07:00
Pierre Tachoire
d9e72049ae ci: use ubuntu 22.04 for arm64 build 2025-06-29 10:48:04 -07:00
Pierre Tachoire
927ca01161 upgrade zig v8 version 2025-06-29 10:47:03 -07:00
Pierre Tachoire
3ea8d0b01c Merge pull request #824 from lightpanda-io/dom-non-html
create a DOM tree for non-html files
2025-06-29 10:44:26 -07:00
Karl Seguin
c52d33e331 Merge pull request #822 from lightpanda-io/undefined_or
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add UndefinedOr(T) union
2025-06-28 09:09:45 +08:00
Karl Seguin
fd36606acc change field order 2025-06-28 09:02:12 +08:00
Karl Seguin
1c6f4a79e0 Merge pull request #821 from lightpanda-io/abort_controller
Abort controller
2025-06-28 09:00:07 +08:00
Pierre Tachoire
7896d274a3 create a DOM tree for non-html files too. 2025-06-27 12:17:03 -07:00
Pierre Tachoire
6937c8ecb4 Merge pull request #823 from lightpanda-io/atob_btoa
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add atob and btoa
2025-06-27 09:21:41 -07:00
Karl Seguin
f02b9566c5 add atob and btoa 2025-06-27 18:36:29 +08:00
Karl Seguin
c9936c2b7e Add UndefinedOr(T) union
Some apis want a value or undefined. For these, we can't use an Optional
return type, null maps to JS null. Adds an Env.UndefinedOr(T) generic
union for such return types.
2025-06-27 17:55:13 +08:00
Karl Seguin
bbd9e5e07c add AbortController API 2025-06-27 17:31:25 +08:00
sjorsdonkers
476fb7ec4e DOMException constructor
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
2025-06-27 08:13:43 +02:00
Karl Seguin
7435274be2 Merge pull request #819 from lightpanda-io/update_tls_lib
Upgrade tlz.zig to latest version
2025-06-27 13:45:28 +08:00
Karl Seguin
08d2ea6a10 abort controller 2025-06-27 13:14:35 +08:00
Karl Seguin
41b7ed6938 Upgrade tlz.zig to latest version
Was seeing pretty frequent TLS errors on reddit. I think I had the wrong max
TLS record size, but figured this was an opportunity to upgrade tls.zig, which
has seen quite a few changes since our last upgrade.

Specifically, the nonblocking TLS logic has been split into two structs: one
for handshaking, and then another to be used to encrypt/decrypt after the h
andshake is complete. The biggest impact here is with respect to keepalive,
since what we want to keepalive is the connection post-handshake, but we don't
have this object until much later.

There was also some general API changes, with respect to state and partially
encrypted/decrypted data which we must now maintain.
2025-06-27 13:14:12 +08:00
Pierre Tachoire
7a311a181b Merge pull request #820 from lightpanda-io/ci-e2e
ci: use hetzner for 2e2 regression perf
2025-06-26 22:12:34 -07:00
Pierre Tachoire
ddcb597710 ci: use hetzner for 2e2 regression perf 2025-06-26 17:37:35 -07:00
Pierre Tachoire
9c75f29875 ci: optimize 2e2 build 2025-06-26 17:04:05 -07:00
Pierre Tachoire
343f3885f7 Merge pull request #817 from lightpanda-io/script_tag_dump
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
dump script tag's text content as-is
2025-06-26 11:27:04 -07:00
Karl Seguin
ed7dfeab84 dump script tag's text content as-is 2025-06-26 12:41:22 +08:00
Karl Seguin
8de27b3674 Merge pull request #813 from lightpanda-io/crypto_get_random_values_fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Crypto.getRandomValues consistency
2025-06-26 11:43:39 +08:00
Karl Seguin
f56b0a5f6d Merge branch 'main' into crypto_get_random_values_fix 2025-06-26 10:25:53 +08:00
Karl Seguin
0a27e1254f Merge pull request #814 from lightpanda-io/root_module_nested_modules
Allow root modules to imported modules
2025-06-26 10:25:10 +08:00
Karl Seguin
3f9b256fcb Merge pull request #812 from lightpanda-io/identity_map_collision
We cannot have empty Zig structs mapping to JS instances
2025-06-26 10:24:09 +08:00
Karl Seguin
9ea9859150 Merge pull request #809 from lightpanda-io/html_element_dataset
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add element.dataset API
2025-06-26 09:18:19 +08:00
Pierre Tachoire
03e3f95d2e Merge pull request #810 from lightpanda-io/proxy-authentication
basic/bearer proxy authentication
2025-06-25 17:31:47 -07:00
Pierre Tachoire
e721b0af92 Merge pull request #816 from lightpanda-io/connect_proxy
Connect proxy
2025-06-25 17:31:27 -07:00
Karl Seguin
e18c589de3 Allow root modules to imported modules
Root modules (non-cacheable) should register their module_id -> URL so that,
if they load a nested module, we can get the full URL of the nested module.
2025-06-25 18:20:55 +08:00
sjorsdonkers
aea34264a9 basic/bearer testing 2025-06-25 12:04:38 +02:00
Karl Seguin
8d3a04235d Crypto.getRandomValues consistency
Crypto.getRandomValues should mutate the given parameter as well as return
the value. This return value must be the same (JsObject) as the input parameter.

There might be more magical ways to solve this, but I opted for both the
simplest and most flexible: adding a `toZig` function to JsObject which does
what js.zig does internally when mapping js values to Zig (and, of course, it
uses the same code).

This allows a caller to receive a JsObject (not too common, but we already do
that in a few places) and return that same JsObject (again, not too common, but
we do have support for returning JsObject directly already). With the main
addition that the JsObjet can now be turned into a Zig type by the caller.
2025-06-25 18:03:26 +08:00
Karl Seguin
9c4088b24c We cannot have empty Zig structs mapping to JS instances
An empty struct will share the same address as its sibling (1) which will cause
an collision in the identity map.

(1) - This depends on Zig's non-guaranteed layout, so the collision might not
be with its sibling, but rather some other [seemingly random] field.
2025-06-25 14:58:09 +08:00
Karl Seguin
1e7ee4e0a1 proxy_type 'simple' renamed to 'forward' 2025-06-25 12:21:44 +08:00
Karl Seguin
ec92f110b3 Change dataset to work directly off DOM element 2025-06-25 12:16:08 +08:00
Karl Seguin
2aa5eb85ad Add element.dataset API
Uses the State to store the dataset, but, on first load, loads the data
attributes from the DOM.
2025-06-25 12:16:08 +08:00
Karl Seguin
2815f02382 Merge pull request #811 from lightpanda-io/crypto-getrandomvalues-return
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Return Random Array from crypto.GetRandomValues
2025-06-25 07:45:04 +08:00
Karl Seguin
8bd7c8dd41 Merge pull request #807 from lightpanda-io/css-util-iface
add CSS utility interface
2025-06-25 07:44:15 +08:00
Karl Seguin
269dcf071f Merge pull request #806 from lightpanda-io/document-range
add AbstractRange and Range
2025-06-25 07:41:52 +08:00
Karl Seguin
997ec7f0bc Merge pull request #805 from lightpanda-io/performance-mark
add PerformanceEntry and PerformanceMark
2025-06-25 07:41:19 +08:00
Muki Kiboigo
d9c26bb77f return array in crypto.getRandomValues 2025-06-24 15:01:32 -07:00
Muki Kiboigo
c0fc3a19c8 add CSS utility interface 2025-06-24 13:55:47 -07:00
Muki Kiboigo
ce638c39e3 add AbstractRange and Range 2025-06-24 12:06:50 -07:00
Muki Kiboigo
6b651cd5e4 add PerformanceEntry and PerformanceMark 2025-06-24 12:04:28 -07:00
sjorsdonkers
4560f31010 basic/bearer proxy authentication 2025-06-24 16:38:58 +02:00
Karl Seguin
c97a32e24b Initial work on CONNECT proxy.
Cannot currently connect to the proxy over TLS (though, once connected, it can
connect to the actual site over TLS). No support for authentication.
2025-06-24 15:10:20 +08:00
Karl Seguin
8a005bc5a1 Merge pull request #808 from lightpanda-io/accept-header
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
http: send an Accept: */* header
2025-06-24 09:42:14 +08:00
Pierre Tachoire
20aabee72e http: send an Accept: */* header 2025-06-23 18:18:04 -07:00
Karl Seguin
a00c2345ee Merge pull request #802 from lightpanda-io/endless_loop_fix_and_dot_slash_stitch
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Don't allow timeouts to be registered when shutting down
2025-06-24 08:57:59 +08:00
Karl Seguin
cb35b3624a Merge pull request #803 from lightpanda-io/inline_module_no_cache
Fix module caching
2025-06-24 08:55:23 +08:00
Karl Seguin
c6f59a7aa6 Merge pull request #804 from lightpanda-io/add_error_event_web_api
add ErrorEvent webapi
2025-06-24 08:54:01 +08:00
Karl Seguin
bf296ad797 add ErrorEvent webapi 2025-06-23 19:04:59 +08:00
Karl Seguin
256540934b reject long timeouts as we're shutting down 2025-06-23 17:27:22 +08:00
Karl Seguin
3c07c0818d improve variable names 2025-06-23 17:19:30 +08:00
Karl Seguin
a01d18ace1 Fix module caching
In https://github.com/lightpanda-io/browser/pull/798 module caching was added.
This was necessary as the same module loaded multiple time should result in the
same v8 module instance.

To make this work, modules became cached by their full URL. The full URL of one
module was also used to determine the full URL of nested modules (full url +
specifier).

With inline scripts, the page URL was used as the full URL. While this is
correct when resolving nested modules, it's incorrect for caching the module
itself. Two inline modules on a page share the same URL, but they aren't the
same and should be cached.

To fix this, inline modules still inherit the page URL, in order to resolve the
correct URL for nested modules, but are themselves never cached.
2025-06-23 17:10:54 +08:00
Karl Seguin
55e02f01dc fix wpt runner 2025-06-23 16:47:31 +08:00
Karl Seguin
fe6ccad485 loop.run now takes a maximum wait time 2025-06-23 16:43:28 +08:00
Karl Seguin
11fe79312d Don't allow timeouts to be registered when shutting down
Currently, a timeout that sets a timeout can cause loop.run to block forever.

Also, cleanup URL stitch with leading './'. The resulting URL was valid, but
since we use the URL as the module cache key, it's important to resolve various
representations of the same URL in the same way.
2025-06-23 15:01:58 +08:00
Karl Seguin
bdb2338b5b Merge pull request #796 from lightpanda-io/docker
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
update dockerfile to multi-arch
2025-06-23 12:51:20 +08:00
Karl Seguin
bbafb048d0 Merge pull request #798 from lightpanda-io/module_loading
Fix module loading
2025-06-23 08:54:17 +08:00
Karl Seguin
9fc2fa51bd Merge pull request #797 from lightpanda-io/template_content
add HTML Template's content attribute
2025-06-23 08:54:00 +08:00
Karl Seguin
d8ec50345a Fix module loading
When V8 calls the ResolveModuleCallback that we give it, it passes the specifier
which is essentially the string given to `from`:

```
import {x} from './blah.js';
```

We were taking that specifier and giving it to the page. The page knew the
currently executing script, an thus could resolve the full URL. Given the full
URL, it could either return the JS content from its module cache or fetch
the source.

At best though, this isn't efficient. If two files import the same module, yes
we cache the src, but we still ask v8 to re-compile it. At worse, it crashes
due to resource exhaustion in the case of cyclical dependencies.

ResolveModuleCallback should instead detect that it has already loaded the
module and return the previously loaded module. Essentially, we shouldn't be
caching the JavaScript source, we should be caching the v8 module.

However, in order to do this, we need more than the specifier, which might only
be a relative path (and thus isn't unique). So, in addition to a module cache,
we now also maintain an module identifier lookup. Given a module, we can get
its full path. Thankfully ResolveModuleCallback gives us the referring module,
so we can look up that modules URL, stitch it to the specifier, and get the
full url (the unique identifier) within the JS runtime.

Need more real world testing, and a fully working example before I celebrate,
but for sites with many import, this appears to improve performance by many
orders of magnitude.
2025-06-20 19:17:55 +08:00
Karl Seguin
9f1cc09ca8 add HTML Template's content attribute 2025-06-20 14:36:56 +08:00
Karl Seguin
5dcc3db36b Merge pull request #795 from lightpanda-io/performance_observer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add dummy PerformanceObserver
2025-06-20 08:01:09 +08:00
Pierre Tachoire
898b73ffc8 update dockerfile to multi-arch 2025-06-19 10:10:14 -07:00
Karl Seguin
c5d49a9d34 Add dummy PerformanceObserver
Adds a dummy PerformanceObserver. Only the supportedEntryTypes static attribute
is supported, and it currently returns an empty array. This hopefully prevents
code from trying to use it. For example, before using it, reddit checks if
specific types are supported and, if not, doesn't use it.

This introduced complexity in the js runtime. Our current approach to
attributes only works with primitive types. Non-primitive types can't be
attached to a FunctionTemplate (v8 will crash saying only primitive types can
be set). Plus, all non primitive types require a context to create anyways.

We now detect "primitive" attributes and "complex" attributes. Primitive
attributes are setup as before. Complex attributes are setup per-context,
requiring another loop through our types to detect & setup on each context
creation.
2025-06-19 18:20:02 +08:00
Karl Seguin
ef9f828d35 Merge pull request #790 from lightpanda-io/css-stylesheet
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Minimal CSSStyleSheet
2025-06-19 10:32:13 +08:00
Karl Seguin
c691764205 Merge pull request #794 from lightpanda-io/window-screen
add Screen and ScreenOrientation
2025-06-19 10:29:52 +08:00
sjorsdonkers
2c940d4fd6 browser context proxyServer 2025-06-19 10:26:33 +08:00
Karl Seguin
54bd55d45d fix CSSStyleSheet prototype 2025-06-19 10:25:13 +08:00
Karl Seguin
0b846b15b1 Merge pull request #789 from lightpanda-io/browsercontext-proxyServer
browser context proxyServer
2025-06-19 10:22:17 +08:00
Muki Kiboigo
269eb7e154 add Screen and ScreenOrientation 2025-06-18 12:53:54 -07:00
Muki Kiboigo
97bc19e4ae clean up various imports in CSSOM 2025-06-18 11:32:28 -07:00
Muki Kiboigo
2656cc7842 Add basic tests for CSSStyleSheet 2025-06-18 11:32:28 -07:00
Muki Kiboigo
ba94818415 add CSSStyleSheet 2025-06-18 11:32:27 -07:00
Pierre Tachoire
ac759a6eed Merge pull request #793 from lightpanda-io/domrect-bottom
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
add top, left, bottom, right to DOMRect
2025-06-18 09:54:08 -07:00
Pierre Tachoire
1839b346a6 Merge pull request #792 from lightpanda-io/fix_current_script_scope
Fixes the scoping of page.current_script
2025-06-18 09:51:41 -07:00
Pierre Tachoire
c1ffe7f8e6 Merge pull request #791 from lightpanda-io/zig_event_target_fix
Fix crash when event.currentTarget is used with EventTargetTBase
2025-06-18 08:26:25 -07:00
Pierre Tachoire
833b4d10bd add top, left, bottom, right to DOMRect 2025-06-18 08:21:33 -07:00
Pierre Tachoire
ce98c336c9 keep EventTargetTBase as the dom_event_target
Mimic a dom_node by adding the refcnt field right after the vtable
pointer.
2025-06-18 07:08:35 -07:00
Karl Seguin
d05619990a Fixes the scoping of page.current_script
This was previously being set back to null before it was actually needed.

Also, added a more logs / log details.
2025-06-18 18:36:00 +08:00
Karl Seguin
8033e41d4a Merge pull request #788 from lightpanda-io/dont_keepalive_unprocess_request
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / puppeteer-perf (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Delay setting the requests' keepalive flag until the request is fully…
2025-06-18 16:46:45 +08:00
sjorsdonkers
60f4eab759 handle no params 2025-06-18 10:07:37 +02:00
sjorsdonkers
d7656ea985 expires dashes and f64 2025-06-18 10:07:37 +02:00
sjorsdonkers
e402998577 JS may not set/get HttpOnly cookies 2025-06-18 10:07:37 +02:00
sjorsdonkers
073f75efa3 CDP Network cookie tests 2025-06-18 10:07:37 +02:00
sjorsdonkers
da414f7eb3 CDP.Storage cookies tests 2025-06-18 10:07:37 +02:00
sjorsdonkers
270b89830a Cleaning up crumbles 2025-06-18 10:07:37 +02:00
sjorsdonkers
74ce7ca416 refactor path / domain parsing 2025-06-18 10:07:37 +02:00
sjorsdonkers
3f4338cb51 wip 2025-06-18 10:07:37 +02:00
sjorsdonkers
30ee41fd0e Network.getCookies 2025-06-18 10:07:37 +02:00
sjorsdonkers
4965fec55c storage cookies 2025-06-18 10:07:37 +02:00
sjorsdonkers
18dff8455c lower case domain 2025-06-18 10:07:37 +02:00
sjorsdonkers
fe16f06aee clearRetainingCapacity 2025-06-18 10:07:37 +02:00
sjorsdonkers
48c1c05a93 setCookie 2025-06-18 10:07:37 +02:00
sjorsdonkers
38dee1166d setCookies 2025-06-18 10:07:37 +02:00
sjorsdonkers
0c6fc68eae deleteCookies 2025-06-18 10:07:37 +02:00
Karl Seguin
223611d89e Fix crash when event.currentTarget is used with EventTargetTBase
When EventTargetTBase is used, we pass the container as the target to libdom.
This is not safe, as libdom is expecting an event_target. We see, for example
that when _dom_event_get_current_target is called, the refcnt is increased.
This works if the current_target is a valid event_target, but if it's a
Zig instance (like the Window) ... we're just altering some bits of the
window instance.

This attempts to add a dummy target to EventTargetTBase which can acts as a
real event_targt in place of the Zig instance.
2025-06-18 14:49:15 +08:00
sjorsdonkers
6f5141d5fb browser context proxyServer 2025-06-17 18:43:12 +02:00
Karl Seguin
a6ac7d9c4e Delay setting the requests' keepalive flag until the request is fully processed
We currently set request._keepalive prematurely. There are [error cases] where
the request could be abandoned before being fully drained. While we do try to
drain in some cases, it isn't always possible. For this reason,
request.keepalive is only set at the end of the request lifecycle, at which
point we know the connection is ready to be re-used.
2025-06-17 19:55:36 +08:00
58 changed files with 13653 additions and 10747 deletions

View File

@@ -17,7 +17,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.1.24'
default: 'v0.1.27'
v8:
description: 'v8 version to install'
required: false

View File

@@ -60,7 +60,7 @@ jobs:
ARCH: aarch64
OS: linux
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-22.04-arm
timeout-minutes: 15
steps:
@@ -76,7 +76,7 @@ jobs:
arch: ${{env.ARCH}}
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}

View File

@@ -65,56 +65,6 @@ jobs:
zig-out/bin/lightpanda
retention-days: 1
puppeteer-perf:
name: puppeteer-perf
needs: zig-build-release
env:
MAX_MEMORY: 30000
MAX_AVG_DURATION: 24
LIGHTPANDA_DISABLE_TELEMETRY: true
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: npm install
- name: download artifact
uses: actions/download-artifact@v4
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- name: run puppeteer
run: |
python3 -m http.server 1234 -d ./public & echo $! > PYTHON.pid
./lightpanda serve & echo $! > LPD.pid
RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1
cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM
kill `cat LPD.pid` `cat PYTHON.pid`
- name: puppeteer result
run: cat puppeteer.out
- name: memory regression
run: |
export LPD_VmHWM=`cat LPD.VmHWM`
echo "Peak resident set size: $LPD_VmHWM"
test "$LPD_VmHWM" -le "$MAX_MEMORY"
- name: duration regression
run: |
export PUPPETEER_AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
echo "puppeteer avg duration: $PUPPETEER_AVG_DURATION"
test "$PUPPETEER_AVG_DURATION" -le "$MAX_AVG_DURATION"
demo-scripts:
name: demo-scripts
needs: zig-build-release
@@ -147,8 +97,10 @@ jobs:
name: cdp-and-hyperfine-bench
needs: zig-build-release
# Don't execute on PR
if: github.event_name != 'pull_request'
env:
MAX_MEMORY: 27000
MAX_AVG_DURATION: 23
LIGHTPANDA_DISABLE_TELEMETRY: true
# use a self host runner.
runs-on: lpd-bench-hetzner
@@ -185,6 +137,18 @@ jobs:
- name: puppeteer result
run: cat puppeteer.out
- name: memory regression
run: |
export LPD_VmHWM=`cat LPD.VmHWM`
echo "Peak resident set size: $LPD_VmHWM"
test "$LPD_VmHWM" -le "$MAX_MEMORY"
- name: duration regression
run: |
export PUPPETEER_AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
echo "puppeteer avg duration: $PUPPETEER_AVG_DURATION"
test "$PUPPETEER_AVG_DURATION" -le "$MAX_AVG_DURATION"
- name: json output
run: |
export AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`

View File

@@ -1,11 +1,11 @@
FROM ubuntu:24.04
FROM debian:stable
ARG MINISIG=0.12
ARG ZIG=0.14.1
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG ARCH=x86_64
ARG V8=13.6.233.8
ARG ZIG_V8=v0.1.24
ARG ZIG_V8=v0.1.27
ARG TARGETPLATFORM
RUN apt-get update -yq && \
apt-get install -yq xz-utils \
@@ -20,30 +20,19 @@ RUN curl --fail -L -O https://github.com/jedisct1/minisign/releases/download/${M
tar xvzf minisign-${MINISIG}-linux.tar.gz
# install zig
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig
RUN minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG}
# clean minisg
RUN rm -fr minisign-0.11-linux.tar.gz minisign-linux
# install zig
RUN tar xvf zig-${ARCH}-linux-${ZIG}.tar.xz && \
RUN case $TARGETPLATFORM in \
"linux/arm64") ARCH="aarch64" ;; \
*) ARCH="x86_64" ;; \
esac && \
curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz && \
curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig && \
minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG} && \
tar xvf zig-${ARCH}-linux-${ZIG}.tar.xz && \
mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \
ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig
# clean up zig install
RUN rm -fr zig-${ARCH}-linux-${ZIG}.tar.xz zig-${ARCH}-linux-${ZIG}.tar.xz.minisig
# force use of http instead of ssh with github
RUN cat <<EOF > /root/.gitconfig
[url "https://github.com/"]
insteadOf="git@github.com:"
EOF
# clone lightpanda
RUN git clone git@github.com:lightpanda-io/browser.git
RUN git clone https://github.com/lightpanda-io/browser.git
WORKDIR /browser
@@ -56,14 +45,18 @@ RUN make install-libiconv && \
make install-mimalloc
# download and install v8
RUN curl --fail -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_linux_${ARCH}.a && \
RUN case $TARGETPLATFORM in \
"linux/arm64") ARCH="aarch64" ;; \
*) ARCH="x86_64" ;; \
esac && \
curl --fail -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_linux_${ARCH}.a && \
mkdir -p v8/out/linux/release/obj/zig/ && \
mv libc_v8.a v8/out/linux/release/obj/zig/libc_v8.a
# build release
RUN make build
FROM ubuntu:24.04
FROM debian:stable-slim
# copy ca certificates
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

View File

@@ -5,18 +5,18 @@
.fingerprint = 0xda130f3af836cea0,
.dependencies = .{
.tls = .{
.url = "https://github.com/ianic/tls.zig/archive/b29a8b45fc59fc2d202769c4f54509bb9e17d0a2.tar.gz",
.hash = "tls-0.1.0-ER2e0uAxBQDm_TmSDdbiiyvAZoh4ejlDD4hW8Fl813xE",
.url = "https://github.com/ianic/tls.zig/archive/8250aa9184fbad99983b32411bbe1a5d2fd6f4b7.tar.gz",
.hash = "tls-0.1.0-ER2e0pU3BQB-UD2_s90uvppceH_h4KZxtHCrCct8L054",
},
.tigerbeetle_io = .{
.url = "https://github.com/lightpanda-io/tigerbeetle-io/archive/61d9652f1a957b7f4db723ea6aa0ce9635e840ce.tar.gz",
.hash = "tigerbeetle_io-0.0.0-ViLgxpyRBAB5BMfIcj3KMXfbJzwARs9uSl8aRy2OXULd",
},
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/1d25fcf3ced688adca3c7a95a138771e4ebba692.tar.gz",
.hash = "v8-0.0.0-xddH61eyAwDICIkLAkfQcxsX4TMCKY80QiSUgNBQqx-u",
},
//.v8 = .{ .path = "../zig-v8-fork" },
//.v8 = .{
// .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/dd087771378ea854452bcb010309fa9ffe5a9cac.tar.gz",
// .hash = "v8-0.0.0-xddH66e8AwBL3O_A8yWQYQIyfMbKHFNVQr_NqM6YjU11",
//},
.v8 = .{ .path = "../zig-v8-fork" },
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
},
}

View File

@@ -3,7 +3,9 @@ const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const Loop = @import("runtime/loop.zig").Loop;
const HttpClient = @import("http/client.zig").Client;
const http = @import("http/client.zig");
const Platform = @import("runtime/js.zig").Platform;
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const Notification = @import("notification.zig").Notification;
@@ -12,9 +14,10 @@ const Notification = @import("notification.zig").Notification;
pub const App = struct {
loop: *Loop,
config: Config,
platform: ?*const Platform,
allocator: Allocator,
telemetry: Telemetry,
http_client: HttpClient,
http_client: http.Client,
app_dir_path: ?[]const u8,
notification: *Notification,
@@ -27,8 +30,11 @@ pub const App = struct {
pub const Config = struct {
run_mode: RunMode,
platform: ?*const Platform = null,
tls_verify_host: bool = true,
http_proxy: ?std.Uri = null,
proxy_type: ?http.ProxyType = null,
proxy_auth: ?http.ProxyAuth = null,
};
pub fn init(allocator: Allocator, config: Config) !*App {
@@ -50,11 +56,14 @@ pub const App = struct {
.loop = loop,
.allocator = allocator,
.telemetry = undefined,
.platform = config.platform,
.app_dir_path = app_dir_path,
.notification = notification,
.http_client = try HttpClient.init(allocator, .{
.http_client = try http.Client.init(allocator, loop, .{
.max_concurrent = 3,
.http_proxy = config.http_proxy,
.proxy_type = config.proxy_type,
.proxy_auth = config.proxy_auth,
.tls_verify_host = config.tls_verify_host,
}),
.config = config,

View File

@@ -28,6 +28,7 @@
const Env = @import("env.zig").Env;
const parser = @import("netsurf.zig");
const DataSet = @import("html/DataSet.zig");
const CSSStyleDeclaration = @import("cssom/css_style_declaration.zig").CSSStyleDeclaration;
// for HTMLScript (but probably needs to be added to more)
@@ -36,6 +37,7 @@ onerror: ?Env.Function = null,
// for HTMLElement
style: CSSStyleDeclaration = .empty,
dataset: ?DataSet = null,
// for html/document
ready_state: ReadyState = .loading,
@@ -58,6 +60,8 @@ active_element: ?*parser.Element = null,
// default (by returning selectedIndex == 0).
explicit_index_set: bool = false,
template_content: ?*parser.DocumentFragment = null,
const ReadyState = enum {
loading,
interactive,

View File

@@ -27,6 +27,8 @@ const App = @import("../app.zig").App;
const Session = @import("session.zig").Session;
const Notification = @import("../notification.zig").Notification;
const log = @import("../log.zig");
const http = @import("../http/client.zig");
// Browser is an instance of the browser.
@@ -47,7 +49,7 @@ pub const Browser = struct {
pub fn init(app: *App) !Browser {
const allocator = app.allocator;
const env = try Env.init(allocator, .{});
const env = try Env.init(allocator, app.platform, .{});
errdefer env.deinit();
const notification = try Notification.init(allocator, app.notification);
@@ -95,7 +97,14 @@ pub const Browser = struct {
}
pub fn runMicrotasks(self: *const Browser) void {
return self.env.runMicrotasks();
self.env.runMicrotasks();
}
pub fn runMessageLoop(self: *const Browser) void {
while (self.env.pumpMessageLoop()) {
log.debug(.browser, "pumpMessageLoop", .{});
}
self.env.runIdleTasks();
}
};

View File

@@ -30,39 +30,39 @@ pub const Console = struct {
timers: std.StringHashMapUnmanaged(u32) = .{},
counts: std.StringHashMapUnmanaged(u32) = .{},
pub fn static_lp(values: []JsObject, page: *Page) !void {
pub fn _lp(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
log.fatal(.console, "lightpanda", .{ .args = try serializeValues(values, page) });
}
pub fn static_log(values: []JsObject, page: *Page) !void {
pub fn _log(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
log.info(.console, "info", .{ .args = try serializeValues(values, page) });
}
pub fn static_info(values: []JsObject, page: *Page) !void {
return static_log(values, page);
pub fn _info(values: []JsObject, page: *Page) !void {
return _log(values, page);
}
pub fn static_debug(values: []JsObject, page: *Page) !void {
pub fn _debug(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
log.debug(.console, "debug", .{ .args = try serializeValues(values, page) });
}
pub fn static_warn(values: []JsObject, page: *Page) !void {
pub fn _warn(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
log.warn(.console, "warn", .{ .args = try serializeValues(values, page) });
}
pub fn static_error(values: []JsObject, page: *Page) !void {
pub fn _error(values: []JsObject, page: *Page) !void {
if (values.len == 0) {
return;
}
@@ -73,7 +73,7 @@ pub const Console = struct {
});
}
pub fn static_clear() void {}
pub fn _clear() void {}
pub fn _count(self: *Console, label_: ?[]const u8, page: *Page) !void {
const label = label_ orelse "default";
@@ -134,7 +134,7 @@ pub const Console = struct {
log.warn(.console, "timer stop", .{ .label = label, .elapsed = elapsed - kv.value });
}
pub fn static_assert(assertion: JsObject, values: []JsObject, page: *Page) !void {
pub fn _assert(assertion: JsObject, values: []JsObject, page: *Page) !void {
if (assertion.isTruthy()) {
return;
}

View File

@@ -17,16 +17,21 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Env = @import("../env.zig").Env;
const uuidv4 = @import("../../id.zig").uuidv4;
// https://w3c.github.io/webcrypto/#crypto-interface
pub const Crypto = struct {
pub fn _getRandomValues(_: *const Crypto, into: RandomValues) !void {
_not_empty: bool = true,
pub fn _getRandomValues(_: *const Crypto, js_obj: Env.JsObject) !Env.JsObject {
var into = try js_obj.toZig(Crypto, "getRandomValues", RandomValues);
const buf = into.asBuffer();
if (buf.len > 65_536) {
return error.QuotaExceededError;
}
std.crypto.random.bytes(buf);
return js_obj;
}
pub fn _randomUUID(_: *const Crypto) [36]u8 {
@@ -47,16 +52,16 @@ const RandomValues = union(enum) {
uint64: []u64,
fn asBuffer(self: RandomValues) []u8 {
switch (self) {
.int8 => |b| return (@as([]u8, @ptrCast(b)))[0..b.len],
.uint8 => |b| return (@as([]u8, @ptrCast(b)))[0..b.len],
.int16 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
.uint16 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
.int32 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
.uint32 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
.int64 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
.uint64 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
}
return switch (self) {
.int8 => |b| (@as([]u8, @ptrCast(b)))[0..b.len],
.uint8 => |b| (@as([]u8, @ptrCast(b)))[0..b.len],
.int16 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
.uint16 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
.int32 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
.uint32 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
.int64 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
.uint64 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
};
}
};
@@ -69,14 +74,24 @@ test "Browser.Crypto" {
.{ "const a = crypto.randomUUID();", "undefined" },
.{ "const b = crypto.randomUUID();", "undefined" },
.{ "a.length;", "36" },
.{ "a.length;", "36" },
.{ "b.length;", "36" },
.{ "a == b;", "false" },
}, .{});
try runner.testCases(&.{
.{ "try { crypto.getRandomValues(new BigUint64Array(8193)) } catch(e) { e.message == 'QuotaExceededError' }", "true" },
.{ "let r1 = new Int32Array(5)", "undefined" },
.{ "crypto.getRandomValues(r1)", "undefined" },
.{ "let r2 = crypto.getRandomValues(r1)", "undefined" },
.{ "new Set(r1).size", "5" },
.{ "new Set(r2).size", "5" },
.{ "r1.every((v, i) => v === r2[i])", "true" },
}, .{});
try runner.testCases(&.{
.{ "var r3 = new Uint8Array(16)", null },
.{ "let r4 = crypto.getRandomValues(r3)", "undefined" },
.{ "r4[6] = 10", null },
.{ "r4[6]", "10" },
.{ "r3[6]", "10" },
}, .{});
}

View File

@@ -23,6 +23,20 @@ const std = @import("std");
const Selector = @import("selector.zig").Selector;
const parser = @import("parser.zig");
pub const Interfaces = .{
Css,
};
// https://developer.mozilla.org/en-US/docs/Web/API/CSS
pub const Css = struct {
_not_empty: bool = true,
pub fn _supports(_: *Css, _: []const u8, _: ?[]const u8) bool {
// TODO: Actually respond with which CSS features we support.
return true;
}
};
// parse parse a selector string and returns the parsed result or an error.
pub fn parse(alloc: std.mem.Allocator, s: []const u8, opts: parser.ParseOptions) parser.ParseError!Selector {
var p = parser.Parser{ .s = s, .i = 0, .opts = opts };
@@ -174,3 +188,14 @@ test "parse" {
defer s.deinit(alloc);
}
}
const testing = @import("../../testing.zig");
test "Browser.HTML.CSS" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "CSS.supports('display: flex')", "true" },
.{ "CSS.supports('text-decoration-style', 'blink')", "true" },
}, .{});
}

View File

@@ -0,0 +1,42 @@
// Copyright (C) 2023-2024 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 CSSStyleSheet = @import("css_stylesheet.zig").CSSStyleSheet;
pub const Interfaces = .{
CSSRule,
CSSImportRule,
};
// https://developer.mozilla.org/en-US/docs/Web/API/CSSRule
pub const CSSRule = struct {
css_text: []const u8,
parent_rule: ?*CSSRule = null,
parent_stylesheet: ?*CSSStyleSheet = null,
};
pub const CSSImportRule = struct {
pub const prototype = *CSSRule;
href: []const u8,
layer_name: ?[]const u8,
media: void,
style_sheet: CSSStyleSheet,
supports_text: ?[]const u8,
};

View File

@@ -0,0 +1,60 @@
// Copyright (C) 2023-2024 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 StyleSheet = @import("stylesheet.zig").StyleSheet;
const CSSRule = @import("css_rule.zig").CSSRule;
const CSSImportRule = @import("css_rule.zig").CSSImportRule;
pub const CSSRuleList = struct {
list: std.ArrayListUnmanaged([]const u8),
pub fn constructor() CSSRuleList {
return .{ .list = .empty };
}
pub fn _item(self: *CSSRuleList, _index: u32) ?CSSRule {
const index: usize = @intCast(_index);
if (index > self.list.items.len) {
return null;
}
// todo: for now, just return null.
// this depends on properly parsing CSSRule
return null;
}
pub fn get_length(self: *CSSRuleList) u32 {
return @intCast(self.list.items.len);
}
};
const testing = @import("../../testing.zig");
test "Browser.CSS.CSSRuleList" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let list = new CSSRuleList()", "undefined" },
.{ "list instanceof CSSRuleList", "true" },
.{ "list.length", "0" },
.{ "list.item(0)", "null" },
}, .{});
}

View File

@@ -20,15 +20,9 @@ const std = @import("std");
const CSSParser = @import("./css_parser.zig").CSSParser;
const CSSValueAnalyzer = @import("./css_value_analyzer.zig").CSSValueAnalyzer;
const CSSRule = @import("css_rule.zig").CSSRule;
const Page = @import("../page.zig").Page;
pub const Interfaces = .{
CSSStyleDeclaration,
CSSRule,
};
const CSSRule = struct {};
pub const CSSStyleDeclaration = struct {
store: std.StringHashMapUnmanaged(Property),
order: std.ArrayListUnmanaged([]const u8),

View File

@@ -0,0 +1,91 @@
// Copyright (C) 2023-2024 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 Page = @import("../page.zig").Page;
const StyleSheet = @import("stylesheet.zig").StyleSheet;
const CSSRuleList = @import("css_rule_list.zig").CSSRuleList;
const CSSImportRule = @import("css_rule.zig").CSSImportRule;
pub const CSSStyleSheet = struct {
pub const prototype = *StyleSheet;
proto: StyleSheet,
css_rules: CSSRuleList,
owner_rule: ?*CSSImportRule,
const CSSStyleSheetOpts = struct {
base_url: ?[]const u8 = null,
// TODO: Suupport media
disabled: bool = false,
};
pub fn constructor(_opts: ?CSSStyleSheetOpts) !CSSStyleSheet {
const opts = _opts orelse CSSStyleSheetOpts{};
return .{
.proto = StyleSheet{ .disabled = opts.disabled },
.css_rules = .constructor(),
.owner_rule = null,
};
}
pub fn get_ownerRule(_: *CSSStyleSheet) ?*CSSImportRule {
return null;
}
pub fn get_cssRules(self: *CSSStyleSheet) *CSSRuleList {
return &self.css_rules;
}
pub fn _insertRule(self: *CSSStyleSheet, rule: []const u8, _index: ?usize, page: *Page) !usize {
const index = _index orelse 0;
if (index > self.css_rules.list.items.len) {
return error.IndexSize;
}
const arena = page.arena;
try self.css_rules.list.insert(arena, index, try arena.dupe(u8, rule));
return index;
}
pub fn _deleteRule(self: *CSSStyleSheet, index: usize) !void {
if (index > self.css_rules.list.items.len) {
return error.IndexSize;
}
_ = self.css_rules.list.orderedRemove(index);
}
};
const testing = @import("../../testing.zig");
test "Browser.CSS.StyleSheet" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let css = new CSSStyleSheet()", "undefined" },
.{ "css instanceof CSSStyleSheet", "true" },
.{ "css.cssRules.length", "0" },
.{ "css.ownerRule", "null" },
.{ "let index1 = css.insertRule('body { color: red; }', 0)", "undefined" },
.{ "index1", "0" },
.{ "css.cssRules.length", "1" },
}, .{});
}

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2023-2024 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/>.
pub const Stylesheet = @import("stylesheet.zig").StyleSheet;
pub const CSSStylesheet = @import("css_stylesheet.zig").CSSStyleSheet;
pub const CSSStyleDeclaration = @import("css_style_declaration.zig").CSSStyleDeclaration;
pub const CSSRuleList = @import("css_rule_list.zig").CSSRuleList;
pub const Interfaces = .{
Stylesheet,
CSSStylesheet,
CSSStyleDeclaration,
CSSRuleList,
@import("css_rule.zig").Interfaces,
};

View File

@@ -0,0 +1,55 @@
// Copyright (C) 2023-2024 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 parser = @import("../netsurf.zig");
// https://developer.mozilla.org/en-US/docs/Web/API/StyleSheet#specifications
pub const StyleSheet = struct {
disabled: bool = false,
href: []const u8 = "",
owner_node: ?*parser.Node = null,
parent_stylesheet: ?*StyleSheet = null,
title: []const u8 = "",
type: []const u8 = "text/css",
pub fn get_disabled(self: *const StyleSheet) bool {
return self.disabled;
}
pub fn get_href(self: *const StyleSheet) []const u8 {
return self.href;
}
// TODO: media
pub fn get_ownerNode(self: *const StyleSheet) ?*parser.Node {
return self.owner_node;
}
pub fn get_parentStyleSheet(self: *const StyleSheet) ?*StyleSheet {
return self.parent_stylesheet;
}
pub fn get_title(self: *const StyleSheet) []const u8 {
return self.title;
}
pub fn get_type(self: *const StyleSheet) []const u8 {
return self.type;
}
};

View File

@@ -32,6 +32,7 @@ const css = @import("css.zig");
const Element = @import("element.zig").Element;
const ElementUnion = @import("element.zig").Union;
const TreeWalker = @import("tree_walker.zig").TreeWalker;
const Range = @import("range.zig").Range;
const Env = @import("../env.zig").Env;
@@ -290,6 +291,10 @@ pub const Document = struct {
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
state.active_element = @ptrCast(e);
}
pub fn _createRange(_: *parser.Document, page: *Page) Range {
return Range.constructor(page);
}
};
const testing = @import("../../testing.zig");

View File

@@ -71,4 +71,17 @@ test "Browser.DOM.DocumentFragment" {
.{ "dc1.isEqualNode(dc1)", "true" },
.{ "dc1.isEqualNode(dc2)", "true" },
}, .{});
try runner.testCases(&.{
.{ "let f = document.createDocumentFragment()", null },
.{ "let d = document.createElement('div');", null },
.{ "d.id = 'x';", null },
.{ "document.getElementById('x') == null;", "true" },
.{ "f.append(d);", null },
.{ "document.getElementById('x') == null;", "true" },
.{ "document.getElementsByTagName('body')[0].append(f.cloneNode(true));", null },
.{ "document.getElementById('x') != null;", "true" },
}, .{});
}

View File

@@ -28,6 +28,7 @@ const IntersectionObserver = @import("intersection_observer.zig");
const DOMParser = @import("dom_parser.zig").DOMParser;
const TreeWalker = @import("tree_walker.zig").TreeWalker;
const NodeFilter = @import("node_filter.zig").NodeFilter;
const PerformanceObserver = @import("performance_observer.zig").PerformanceObserver;
pub const Interfaces = .{
DOMException,
@@ -44,4 +45,7 @@ pub const Interfaces = .{
DOMParser,
TreeWalker,
NodeFilter,
@import("performance.zig").Interfaces,
PerformanceObserver,
@import("range.zig").Interfaces,
};

View File

@@ -43,6 +43,10 @@ pub const Element = struct {
y: f64,
width: f64,
height: f64,
bottom: f64,
right: f64,
top: f64,
left: f64,
};
pub fn toInterface(e: *parser.Element) !Union {
@@ -369,7 +373,16 @@ pub const Element = struct {
pub fn _getBoundingClientRect(self: *parser.Element, page: *Page) !DOMRect {
// Since we are lazy rendering we need to do this check. We could store the renderer in a viewport such that it could cache these, but it would require tracking changes.
if (!try page.isNodeAttached(parser.elementToNode(self))) {
return DOMRect{ .x = 0, .y = 0, .width = 0, .height = 0 };
return DOMRect{
.x = 0,
.y = 0,
.width = 0,
.height = 0,
.bottom = 0,
.right = 0,
.top = 0,
.left = 0,
};
}
return page.renderer.getRect(self);
}

View File

@@ -33,16 +33,26 @@ pub const EventTarget = struct {
pub const Self = parser.EventTarget;
pub const Exception = DOMException;
pub fn toInterface(et: *parser.EventTarget, page: *Page) !Union {
// Not all targets are *parser.Nodes. page.zig emits a "load" event
// where the target is a Window, which cannot be cast directly to a node.
// Ideally, we'd remove this duality. Failing that, we'll need to embed
// data into the *parser.EventTarget should we need this for other types.
// For now, for the Window, which is a singleton, we can do this:
pub fn toInterface(e: *parser.Event, et: *parser.EventTarget, page: *Page) !Union {
// libdom assumes that all event targets are libdom nodes. They are not.
// The window is a common non-node target, but it's easy to handle as
// its a singleton.
if (@intFromPtr(et) == @intFromPtr(&page.window.base)) {
return .{ .Window = &page.window };
}
// AbortSignal is another non-node target. It has a distinct usage though
// so we hijack the event internal type to identity if.
switch (try parser.eventGetInternalType(e)) {
.abort_signal => {
return .{ .AbortSignal = @fieldParentPtr("proto", @as(*parser.EventTargetTBase, @ptrCast(et))) };
},
else => {
// some of these probably need to be special-cased like abort_signal
return Nod.Node.toInterface(@as(*parser.Node, @ptrCast(et)));
},
}
}
// JS funcs

View File

@@ -20,10 +20,11 @@ const std = @import("std");
const allocPrint = std.fmt.allocPrint;
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
// https://webidl.spec.whatwg.org/#idl-DOMException
pub const DOMException = struct {
err: parser.DOMError,
err: ?parser.DOMError,
str: []const u8,
pub const ErrorSet = parser.DOMError;
@@ -55,6 +56,17 @@ pub const DOMException = struct {
pub const _INVALID_NODE_TYPE_ERR = 24;
pub const _DATA_CLONE_ERR = 25;
pub fn constructor(message_: ?[]const u8, name_: ?[]const u8, page: *const Page) !DOMException {
const message = message_ orelse "";
const err = if (name_) |n| error_from_str(n) else null;
const fixed_name = name(err);
if (message.len == 0) return .{ .err = err, .str = fixed_name };
const str = try allocPrint(page.arena, "{s}: {s}", .{ fixed_name, message });
return .{ .err = err, .str = str };
}
// TODO: deinit
pub fn init(alloc: std.mem.Allocator, err: anyerror, callerName: []const u8) !DOMException {
const errCast = @as(parser.DOMError, @errorCast(err));
@@ -75,14 +87,52 @@ pub const DOMException = struct {
return .{ .err = errCast, .str = str };
}
fn name(err: parser.DOMError) []const u8 {
fn error_from_str(name_: []const u8) ?parser.DOMError {
// @speed: Consider length first, left as is for maintainability, awaiting switch on string support
if (std.mem.eql(u8, name_, "IndexSizeError")) return error.IndexSize;
if (std.mem.eql(u8, name_, "StringSizeError")) return error.StringSize;
if (std.mem.eql(u8, name_, "HierarchyRequestError")) return error.HierarchyRequest;
if (std.mem.eql(u8, name_, "WrongDocumentError")) return error.WrongDocument;
if (std.mem.eql(u8, name_, "InvalidCharacterError")) return error.InvalidCharacter;
if (std.mem.eql(u8, name_, "NoDataAllowedError")) return error.NoDataAllowed;
if (std.mem.eql(u8, name_, "NoModificationAllowedError")) return error.NoModificationAllowed;
if (std.mem.eql(u8, name_, "NotFoundError")) return error.NotFound;
if (std.mem.eql(u8, name_, "NotSupportedError")) return error.NotSupported;
if (std.mem.eql(u8, name_, "InuseAttributeError")) return error.InuseAttribute;
if (std.mem.eql(u8, name_, "InvalidStateError")) return error.InvalidState;
if (std.mem.eql(u8, name_, "SyntaxError")) return error.Syntax;
if (std.mem.eql(u8, name_, "InvalidModificationError")) return error.InvalidModification;
if (std.mem.eql(u8, name_, "NamespaceError")) return error.Namespace;
if (std.mem.eql(u8, name_, "InvalidAccessError")) return error.InvalidAccess;
if (std.mem.eql(u8, name_, "ValidationError")) return error.Validation;
if (std.mem.eql(u8, name_, "TypeMismatchError")) return error.TypeMismatch;
if (std.mem.eql(u8, name_, "SecurityError")) return error.Security;
if (std.mem.eql(u8, name_, "NetworkError")) return error.Network;
if (std.mem.eql(u8, name_, "AbortError")) return error.Abort;
if (std.mem.eql(u8, name_, "URLismatchError")) return error.URLismatch;
if (std.mem.eql(u8, name_, "QuotaExceededError")) return error.QuotaExceeded;
if (std.mem.eql(u8, name_, "TimeoutError")) return error.Timeout;
if (std.mem.eql(u8, name_, "InvalidNodeTypeError")) return error.InvalidNodeType;
if (std.mem.eql(u8, name_, "DataCloneError")) return error.DataClone;
// custom netsurf error
if (std.mem.eql(u8, name_, "UnspecifiedEventTypeError")) return error.UnspecifiedEventType;
if (std.mem.eql(u8, name_, "DispatchRequestError")) return error.DispatchRequest;
if (std.mem.eql(u8, name_, "NoMemoryError")) return error.NoMemory;
if (std.mem.eql(u8, name_, "AttributeWrongTypeError")) return error.AttributeWrongType;
return null;
}
fn name(err_: ?parser.DOMError) []const u8 {
const err = err_ orelse return "Error";
return switch (err) {
error.IndexSize => "IndexSizeError",
error.StringSize => "StringSizeError",
error.StringSize => "StringSizeError", // Legacy: DOMSTRING_SIZE_ERR
error.HierarchyRequest => "HierarchyRequestError",
error.WrongDocument => "WrongDocumentError",
error.InvalidCharacter => "InvalidCharacterError",
error.NoDataAllowed => "NoDataAllowedError",
error.NoDataAllowed => "NoDataAllowedError", // Legacy: NO_DATA_ALLOWED_ERR
error.NoModificationAllowed => "NoModificationAllowedError",
error.NotFound => "NotFoundError",
error.NotSupported => "NotSupportedError",
@@ -92,7 +142,7 @@ pub const DOMException = struct {
error.InvalidModification => "InvalidModificationError",
error.Namespace => "NamespaceError",
error.InvalidAccess => "InvalidAccessError",
error.Validation => "ValidationError",
error.Validation => "ValidationError", // Legacy: VALIDATION_ERR
error.TypeMismatch => "TypeMismatchError",
error.Security => "SecurityError",
error.Network => "NetworkError",
@@ -115,7 +165,8 @@ pub const DOMException = struct {
// JS properties and methods
pub fn get_code(self: *const DOMException) u8 {
return switch (self.err) {
const err = self.err orelse return 0;
return switch (err) {
error.IndexSize => 1,
error.StringSize => 2,
error.HierarchyRequest => 3,
@@ -157,7 +208,8 @@ pub const DOMException = struct {
pub fn get_message(self: *const DOMException) []const u8 {
const errName = DOMException.name(self.err);
return self.str[errName.len + 2 ..];
if (self.str.len <= errName.len + 2) return "";
return self.str[errName.len + 2 ..]; // ! Requires str is formatted as "{name}: {message}"
}
pub fn _toString(self: *const DOMException) []const u8 {
@@ -188,4 +240,25 @@ test "Browser.DOM.Exception" {
.{ "he instanceof DOMException", "true" },
.{ "he instanceof Error", "true" },
}, .{});
// Test DOMException constructor
try runner.testCases(&.{
.{ "let exc0 = new DOMException()", "undefined" },
.{ "exc0.name", "Error" },
.{ "exc0.code", "0" },
.{ "exc0.message", "" },
.{ "exc0.toString()", "Error" },
.{ "let exc1 = new DOMException('Sandwich malfunction')", "undefined" },
.{ "exc1.name", "Error" },
.{ "exc1.code", "0" },
.{ "exc1.message", "Sandwich malfunction" },
.{ "exc1.toString()", "Error: Sandwich malfunction" },
.{ "let exc2 = new DOMException('Caterpillar turned into a butterfly', 'NoModificationAllowedError')", "undefined" },
.{ "exc2.name", "NoModificationAllowedError" },
.{ "exc2.code", "7" },
.{ "exc2.message", "Caterpillar turned into a butterfly" },
.{ "exc2.toString()", "NoModificationAllowedError: Caterpillar turned into a butterfly" },
}, .{});
}

View File

@@ -0,0 +1,203 @@
// 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 parser = @import("../netsurf.zig");
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const Env = @import("../env.zig").Env;
const Page = @import("../page.zig").Page;
pub const Interfaces = .{
Performance,
PerformanceEntry,
PerformanceMark,
};
const MarkOptions = struct {
detail: ?Env.JsObject = null,
start_time: ?f64 = null,
};
// https://developer.mozilla.org/en-US/docs/Web/API/Performance
pub const Performance = struct {
pub const prototype = *EventTarget;
// Extend libdom event target for pure zig struct.
base: parser.EventTargetTBase = parser.EventTargetTBase{},
time_origin: std.time.Timer,
// if (Window.crossOriginIsolated) -> Resolution in isolated contexts: 5 microseconds
// else -> Resolution in non-isolated contexts: 100 microseconds
const ms_resolution = 100;
fn limitedResolutionMs(nanoseconds: u64) f64 {
const elapsed_at_resolution = ((nanoseconds / std.time.ns_per_us) + ms_resolution / 2) / ms_resolution * ms_resolution;
const elapsed = @as(f64, @floatFromInt(elapsed_at_resolution));
return elapsed / @as(f64, std.time.us_per_ms);
}
pub fn get_timeOrigin(self: *const Performance) f64 {
const is_posix = switch (@import("builtin").os.tag) { // From std.time.zig L125
.windows, .uefi, .wasi => false,
else => true,
};
const zero = std.time.Instant{ .timestamp = if (!is_posix) 0 else .{ .sec = 0, .nsec = 0 } };
const started = self.time_origin.started.since(zero);
return limitedResolutionMs(started);
}
pub fn _now(self: *Performance) f64 {
return limitedResolutionMs(self.time_origin.read());
}
pub fn _mark(_: *Performance, name: []const u8, _options: ?MarkOptions, page: *Page) !PerformanceMark {
const mark: PerformanceMark = try .constructor(name, _options, page);
// TODO: Should store this in an entries list
return mark;
}
};
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry
pub const PerformanceEntry = struct {
const PerformanceEntryType = enum {
element,
event,
first_input,
largest_contentful_paint,
layout_shift,
long_animation_frame,
longtask,
mark,
measure,
navigation,
paint,
resource,
taskattribution,
visibility_state,
pub fn toString(self: PerformanceEntryType) []const u8 {
return switch (self) {
.first_input => "first-input",
.largest_contentful_paint => "largest-contentful-paint",
.layout_shift => "layout-shift",
.long_animation_frame => "long-animation-frame",
.visibility_state => "visibility-state",
else => @tagName(self),
};
}
};
duration: f64 = 0.0,
entry_type: PerformanceEntryType,
name: []const u8,
start_time: f64 = 0.0,
pub fn get_duration(self: *const PerformanceEntry) f64 {
return self.duration;
}
pub fn get_entryType(self: *const PerformanceEntry) PerformanceEntryType {
return self.entry_type;
}
pub fn get_name(self: *const PerformanceEntry) []const u8 {
return self.name;
}
pub fn get_startTime(self: *const PerformanceEntry) f64 {
return self.start_time;
}
};
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceMark
pub const PerformanceMark = struct {
pub const prototype = *PerformanceEntry;
proto: PerformanceEntry,
detail: ?Env.JsObject,
pub fn constructor(name: []const u8, _options: ?MarkOptions, page: *Page) !PerformanceMark {
const perf = &page.window.performance;
const options = _options orelse MarkOptions{};
const start_time = options.start_time orelse perf._now();
const detail = if (options.detail) |d| try d.persist() else null;
if (start_time < 0.0) {
return error.TypeError;
}
const duped_name = try page.arena.dupe(u8, name);
const proto = PerformanceEntry{ .name = duped_name, .entry_type = .mark, .start_time = start_time };
return .{ .proto = proto, .detail = detail };
}
pub fn get_detail(self: *const PerformanceMark) ?Env.JsObject {
return self.detail;
}
};
const testing = @import("./../../testing.zig");
test "Performance: get_timeOrigin" {
var perf = Performance{ .time_origin = try std.time.Timer.start() };
const time_origin = perf.get_timeOrigin();
try testing.expect(time_origin >= 0);
// Check resolution
try testing.expectDelta(@rem(time_origin * std.time.us_per_ms, 100.0), 0.0, 0.1);
}
test "Performance: now" {
var perf = Performance{ .time_origin = try std.time.Timer.start() };
// Monotonically increasing
var now = perf._now();
while (now <= 0) { // Loop for now to not be 0
try testing.expectEqual(now, 0);
now = perf._now();
}
// Check resolution
try testing.expectDelta(@rem(now * std.time.us_per_ms, 100.0), 0.0, 0.1);
var after = perf._now();
while (after <= now) { // Loop untill after > now
try testing.expectEqual(after, now);
after = perf._now();
}
// Check resolution
try testing.expectDelta(@rem(after * std.time.us_per_ms, 100.0), 0.0, 0.1);
}
test "Browser.Performance.Mark" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let performance = window.performance", "undefined" },
.{ "performance instanceof Performance", "true" },
.{ "let mark = performance.mark(\"start\")", "undefined" },
.{ "mark instanceof PerformanceMark", "true" },
.{ "mark.name", "start" },
.{ "mark.entryType", "mark" },
.{ "mark.duration", "0" },
.{ "mark.detail", "null" },
}, .{});
}

View File

@@ -0,0 +1,34 @@
// 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");
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver
pub const PerformanceObserver = struct {
pub const _supportedEntryTypes = [0][]const u8{};
};
const testing = @import("../../testing.zig");
test "Browser.DOM.PerformanceObserver" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "PerformanceObserver.supportedEntryTypes.length", "0" },
}, .{});
}

178
src/browser/dom/range.zig Normal file
View File

@@ -0,0 +1,178 @@
// Copyright (C) 2023-2024 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 parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const NodeUnion = @import("node.zig").Union;
const Node = @import("node.zig").Node;
pub const Interfaces = .{
AbstractRange,
Range,
};
pub const AbstractRange = struct {
collapsed: bool,
end_container: *parser.Node,
end_offset: i32,
start_container: *parser.Node,
start_offset: i32,
pub fn updateCollapsed(self: *AbstractRange) void {
// TODO: Eventually, compare properly.
self.collapsed = false;
}
pub fn get_collapsed(self: *const AbstractRange) bool {
return self.collapsed;
}
pub fn get_endContainer(self: *const AbstractRange) !NodeUnion {
return Node.toInterface(self.end_container);
}
pub fn get_endOffset(self: *const AbstractRange) i32 {
return self.end_offset;
}
pub fn get_startContainer(self: *const AbstractRange) !NodeUnion {
return Node.toInterface(self.start_container);
}
pub fn get_startOffset(self: *const AbstractRange) i32 {
return self.start_offset;
}
};
pub const Range = struct {
pub const prototype = *AbstractRange;
proto: AbstractRange,
// The Range() constructor returns a newly created Range object whose start
// and end is the global Document object.
// https://developer.mozilla.org/en-US/docs/Web/API/Range/Range
pub fn constructor(page: *Page) Range {
const proto: AbstractRange = .{
.collapsed = true,
.end_container = parser.documentHTMLToNode(page.window.document),
.end_offset = 0,
.start_container = parser.documentHTMLToNode(page.window.document),
.start_offset = 0,
};
return .{ .proto = proto };
}
pub fn _setStart(self: *Range, node: *parser.Node, offset: i32) void {
self.proto.start_container = node;
self.proto.start_offset = offset;
self.proto.updateCollapsed();
}
pub fn _setEnd(self: *Range, node: *parser.Node, offset: i32) void {
self.proto.end_container = node;
self.proto.end_offset = offset;
self.proto.updateCollapsed();
}
pub fn _createContextualFragment(_: *Range, fragment: []const u8, page: *Page) !*parser.DocumentFragment {
const document_html = page.window.document;
const document = parser.documentHTMLToDocument(document_html);
const doc_frag = try parser.documentParseFragmentFromStr(document, fragment);
return doc_frag;
}
pub fn _selectNodeContents(self: *Range, node: *parser.Node) !void {
self.proto.start_container = node;
self.proto.start_offset = 0;
self.proto.end_container = node;
// Set end_offset
switch (try parser.nodeType(node)) {
.text, .cdata_section, .comment, .processing_instruction => {
// For text-like nodes, end_offset should be the length of the text data
if (try parser.nodeValue(node)) |text_data| {
self.proto.end_offset = @intCast(text_data.len);
} else {
self.proto.end_offset = 0;
}
},
else => {
// For element and other nodes, end_offset is the number of children
const child_nodes = try parser.nodeGetChildNodes(node);
const child_count = try parser.nodeListLength(child_nodes);
self.proto.end_offset = @intCast(child_count);
},
}
self.proto.updateCollapsed();
}
// The Range.detach() method does nothing. It used to disable the Range
// object and enable the browser to release associated resources. The
// method has been kept for compatibility.
// https://developer.mozilla.org/en-US/docs/Web/API/Range/detach
pub fn _detach(_: *Range) void {}
};
const testing = @import("../../testing.zig");
test "Browser.Range" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
// Test Range constructor
.{ "let range = new Range()", "undefined" },
.{ "range instanceof Range", "true" },
.{ "range instanceof AbstractRange", "true" },
// Test initial state - collapsed range
.{ "range.collapsed", "true" },
.{ "range.startOffset", "0" },
.{ "range.endOffset", "0" },
.{ "range.startContainer instanceof HTMLDocument", "true" },
.{ "range.endContainer instanceof HTMLDocument", "true" },
// Test document.createRange()
.{ "let docRange = document.createRange()", "undefined" },
.{ "docRange instanceof Range", "true" },
.{ "docRange.collapsed", "true" },
}, .{});
try runner.testCases(&.{
.{ "const container = document.getElementById('content');", null },
// Test text range
.{ "const commentNode = container.childNodes[7];", null },
.{ "commentNode.nodeValue", "comment" },
.{ "const textRange = document.createRange();", null },
.{ "textRange.selectNodeContents(commentNode)", "undefined" },
.{ "textRange.startOffset", "0" },
.{ "textRange.endOffset", "7" }, // length of `comment`
// Test Node range
.{ "const nodeRange = document.createRange();", null },
.{ "nodeRange.selectNodeContents(container)", "undefined" },
.{ "nodeRange.startOffset", "0" },
.{ "nodeRange.endOffset", "9" }, // length of container.childNodes
}, .{});
}

View File

@@ -82,9 +82,13 @@ pub fn writeNode(node: *parser.Node, writer: anytype) anyerror!void {
// void elements can't have any content.
if (try isVoid(parser.nodeToElement(node))) return;
if (try parser.elementHTMLGetTagType(@ptrCast(node)) == .script) {
try writer.writeAll(try parser.nodeTextContent(node) orelse "");
} else {
// write the children
// TODO avoid recursion
try writeChildren(node, writer);
}
// close the tag
try writer.writeAll("</");
@@ -211,6 +215,11 @@ test "dump.writeHTML" {
\\</head><body>9000</body></html>
\\
, "<html><title>It's over what?</title><meta name=a value=\"b\">\n<body>9000");
try testWriteHTML(
"<p>hi</p><script>alert(power > 9000)</script>",
"<p>hi</p><script>alert(power > 9000)</script>",
);
}
fn testWriteHTML(comptime expected_body: []const u8, src: []const u8) !void {

View File

@@ -22,7 +22,8 @@ const WebApis = struct {
pub const Interfaces = generate.Tuple(.{
@import("crypto/crypto.zig").Crypto,
@import("console/console.zig").Console,
@import("cssom/css_style_declaration.zig").Interfaces,
@import("css/css.zig").Interfaces,
@import("cssom/cssom.zig").Interfaces,
@import("dom/dom.zig").Interfaces,
@import("encoding/text_encoder.zig").Interfaces,
@import("events/event.zig").Interfaces,

View File

@@ -31,14 +31,10 @@ const EventTargetUnion = @import("../dom/event_target.zig").Union;
const CustomEvent = @import("custom_event.zig").CustomEvent;
const ProgressEvent = @import("../xhr/progress_event.zig").ProgressEvent;
const MouseEvent = @import("mouse_event.zig").MouseEvent;
const ErrorEvent = @import("../html/error_event.zig").ErrorEvent;
// Event interfaces
pub const Interfaces = .{
Event,
CustomEvent,
ProgressEvent,
MouseEvent,
};
pub const Interfaces = .{ Event, CustomEvent, ProgressEvent, MouseEvent, ErrorEvent };
pub const Union = generate.Union(Interfaces);
@@ -58,10 +54,11 @@ pub const Event = struct {
pub fn toInterface(evt: *parser.Event) !Union {
return switch (try parser.eventGetInternalType(evt)) {
.event => .{ .Event = evt },
.event, .abort_signal => .{ .Event = evt },
.custom_event => .{ .CustomEvent = @as(*CustomEvent, @ptrCast(evt)).* },
.progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* },
.mouse_event => .{ .MouseEvent = @as(*parser.MouseEvent, @ptrCast(evt)) },
.error_event => .{ .ErrorEvent = @as(*ErrorEvent, @ptrCast(evt)).* },
};
}
@@ -80,13 +77,13 @@ pub const Event = struct {
pub fn get_target(self: *parser.Event, page: *Page) !?EventTargetUnion {
const et = try parser.eventTarget(self);
if (et == null) return null;
return try EventTarget.toInterface(et.?, page);
return try EventTarget.toInterface(self, et.?, page);
}
pub fn get_currentTarget(self: *parser.Event, page: *Page) !?EventTargetUnion {
const et = try parser.eventCurrentTarget(self);
if (et == null) return null;
return try EventTarget.toInterface(et.?, page);
return try EventTarget.toInterface(self, et.?, page);
}
pub fn get_eventPhase(self: *parser.Event) !u8 {

View File

@@ -0,0 +1,188 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const Env = @import("../env.zig").Env;
const Page = @import("../page.zig").Page;
const Loop = @import("../../runtime/loop.zig").Loop;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
pub const Interfaces = .{
AbortController,
AbortSignal,
};
const AbortController = @This();
signal: *AbortSignal,
pub fn constructor(page: *Page) !AbortController {
// Why do we allocate this rather than storing directly in the struct?
// https://github.com/lightpanda-io/project/discussions/165
const signal = try page.arena.create(AbortSignal);
signal.* = .init;
return .{
.signal = signal,
};
}
pub fn get_signal(self: *AbortController) *AbortSignal {
return self.signal;
}
pub fn _abort(self: *AbortController, reason_: ?[]const u8) !void {
return self.signal.abort(reason_);
}
pub const AbortSignal = struct {
const DEFAULT_REASON = "AbortError";
pub const prototype = *EventTarget;
proto: parser.EventTargetTBase = .{},
aborted: bool,
reason: ?[]const u8,
pub const init: AbortSignal = .{
.proto = .{},
.reason = null,
.aborted = false,
};
pub fn static_abort(reason_: ?[]const u8) AbortSignal {
return .{
.aborted = true,
.reason = reason_ orelse DEFAULT_REASON,
};
}
pub fn static_timeout(delay: u32, page: *Page) !*AbortSignal {
const callback = try page.arena.create(TimeoutCallback);
callback.* = .{
.signal = .init,
.node = .{ .func = TimeoutCallback.run },
};
const delay_ms: u63 = @as(u63, delay) * std.time.ns_per_ms;
_ = try page.loop.timeout(delay_ms, &callback.node);
return &callback.signal;
}
pub fn get_aborted(self: *const AbortSignal) bool {
return self.aborted;
}
fn abort(self: *AbortSignal, reason_: ?[]const u8) !void {
self.aborted = true;
self.reason = reason_ orelse DEFAULT_REASON;
const abort_event = try parser.eventCreate();
try parser.eventSetInternalType(abort_event, .abort_signal);
defer parser.eventDestroy(abort_event);
try parser.eventInit(abort_event, "abort", .{});
_ = try parser.eventTargetDispatchEvent(
parser.toEventTarget(AbortSignal, self),
abort_event,
);
}
const Reason = union(enum) {
reason: []const u8,
undefined: void,
};
pub fn get_reason(self: *const AbortSignal) Reason {
if (self.reason) |r| {
return .{ .reason = r };
}
return .{ .undefined = {} };
}
const ThrowIfAborted = union(enum) {
exception: Env.Exception,
undefined: void,
};
pub fn _throwIfAborted(self: *const AbortSignal, page: *Page) ThrowIfAborted {
if (self.aborted) {
const ex = page.main_context.throw(self.reason orelse DEFAULT_REASON);
return .{ .exception = ex };
}
return .{ .undefined = {} };
}
};
const TimeoutCallback = struct {
signal: AbortSignal,
// This is the internal data that the event loop tracks. We'll get this
// back in run and, from it, can get our TimeoutCallback instance
node: Loop.CallbackNode = undefined,
fn run(node: *Loop.CallbackNode, _: *?u63) void {
const self: *TimeoutCallback = @fieldParentPtr("node", node);
self.signal.abort("TimeoutError") catch |err| {
log.warn(.app, "abort signal timeout", .{ .err = err });
};
}
};
const testing = @import("../../testing.zig");
test "Browser.HTML.AbortController" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "var called = 0", null },
.{ "var a1 = new AbortController()", null },
.{ "var s1 = a1.signal", null },
.{ "s1.throwIfAborted()", "undefined" },
.{ "s1.reason", "undefined" },
.{ "var target;", null },
.{
\\ s1.addEventListener('abort', (e) => {
\\ called += 1;
\\ target = e.target;
\\
\\ });
,
null,
},
.{ "a1.abort()", null },
.{ "s1.aborted", "true" },
.{ "target == s1", "true" },
.{ "s1.reason", "AbortError" },
.{ "called", "1" },
}, .{});
try runner.testCases(&.{
.{ "var s2 = AbortSignal.abort('over 9000')", null },
.{ "s2.aborted", "true" },
.{ "s2.reason", "over 9000" },
.{ "AbortSignal.abort().reason", "AbortError" },
}, .{});
try runner.testCases(&.{
.{ "var s3 = AbortSignal.timeout(10)", null },
.{ "s3.aborted", "true" },
.{ "s3.reason", "TimeoutError" },
.{ "try { s3.throwIfAborted() } catch (e) { e }", "Error: TimeoutError" },
}, .{});
}

View File

@@ -0,0 +1,97 @@
// Copyright (C) 2023-2024 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 parser = @import("../netsurf.zig");
const Env = @import("../env.zig").Env;
const Page = @import("../page.zig").Page;
const Allocator = std.mem.Allocator;
const DataSet = @This();
element: *parser.Element,
pub fn named_get(self: *const DataSet, name: []const u8, _: *bool, page: *Page) !Env.UndefinedOr([]const u8) {
const normalized_name = try normalize(page.call_arena, name);
if (try parser.elementGetAttribute(self.element, normalized_name)) |value| {
return .{ .value = value };
}
return .undefined;
}
pub fn named_set(self: *DataSet, name: []const u8, value: []const u8, _: *bool, page: *Page) !void {
const normalized_name = try normalize(page.call_arena, name);
try parser.elementSetAttribute(self.element, normalized_name, value);
}
pub fn named_delete(self: *DataSet, name: []const u8, _: *bool, page: *Page) !void {
const normalized_name = try normalize(page.call_arena, name);
try parser.elementRemoveAttribute(self.element, normalized_name);
}
fn normalize(allocator: Allocator, name: []const u8) ![]const u8 {
var upper_count: usize = 0;
for (name) |c| {
if (std.ascii.isUpper(c)) {
upper_count += 1;
}
}
// for every upper-case letter, we'll probably need a dash before it
// and we need the 'data-' prefix
var normalized = try allocator.alloc(u8, name.len + upper_count + 5);
@memcpy(normalized[0..5], "data-");
if (upper_count == 0) {
@memcpy(normalized[5..], name);
return normalized;
}
var pos: usize = 5;
for (name) |c| {
if (std.ascii.isUpper(c)) {
normalized[pos] = '-';
pos += 1;
normalized[pos] = c + 32;
} else {
normalized[pos] = c;
}
pos += 1;
}
return normalized;
}
const testing = @import("../../testing.zig");
test "Browser.HTML.DataSet" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "" });
defer runner.deinit();
try runner.testCases(&.{
.{ "let el1 = document.createElement('div')", null },
.{ "el1.dataset.x", "undefined" },
.{ "el1.dataset.x = '123'", "123" },
.{ "delete el1.dataset.x", "true" },
.{ "el1.dataset.x", "undefined" },
.{ "delete el1.dataset.other", "true" }, // yes, this is right
.{ "let ds1 = el1.dataset", null },
.{ "ds1.helloWorld = 'yes'", null },
.{ "el1.getAttribute('data-hello-world')", "yes" },
.{ "el1.setAttribute('data-this-will-work', 'positive')", null },
.{ "ds1.thisWillWork", "positive" },
}, .{});
}

View File

@@ -81,7 +81,7 @@ pub const HTMLDocument = struct {
pub fn get_cookie(_: *parser.DocumentHTML, page: *Page) ![]const u8 {
var buf: std.ArrayListUnmanaged(u8) = .{};
try page.cookie_jar.forRequest(&page.url.uri, buf.writer(page.arena), .{ .navigation = true });
try page.cookie_jar.forRequest(&page.url.uri, buf.writer(page.arena), .{ .navigation = true, .is_http = false });
return buf.items;
}
@@ -90,6 +90,10 @@ pub const HTMLDocument = struct {
// outlives the page's arena.
const c = try Cookie.parse(page.cookie_jar.allocator, &page.url.uri, cookie_str);
errdefer c.deinit();
if (c.http_only) {
c.deinit();
return ""; // HttpOnly cookies cannot be set from JS
}
try page.cookie_jar.add(c, std.time.timestamp());
return cookie_str;
}
@@ -333,6 +337,8 @@ test "Browser.HTML.Document" {
.{ "document.cookie = 'name=Oeschger; SameSite=None; Secure'", "name=Oeschger; SameSite=None; Secure" },
.{ "document.cookie = 'favorite_food=tripe; SameSite=None; Secure'", "favorite_food=tripe; SameSite=None; Secure" },
.{ "document.cookie", "name=Oeschger; favorite_food=tripe" },
.{ "document.cookie = 'IgnoreMy=Ghost; HttpOnly'", null }, // "" should be returned, but the framework overrules it atm
.{ "document.cookie", "name=Oeschger; favorite_food=tripe" },
}, .{});
try runner.testCases(&.{

View File

@@ -27,6 +27,7 @@ const urlStitch = @import("../../url.zig").URL.stitch;
const URL = @import("../url/url.zig").URL;
const Node = @import("../dom/node.zig").Node;
const Element = @import("../dom/element.zig").Element;
const DataSet = @import("DataSet.zig");
const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration;
@@ -122,6 +123,15 @@ pub const HTMLElement = struct {
return &state.style;
}
pub fn get_dataset(e: *parser.ElementHTML, page: *Page) !*DataSet {
const state = try page.getOrCreateNodeState(@ptrCast(e));
if (state.dataset) |*ds| {
return ds;
}
state.dataset = DataSet{ .element = @ptrCast(e) };
return &state.dataset.?;
}
pub fn get_innerText(e: *parser.ElementHTML) ![]const u8 {
const n = @as(*parser.Node, @ptrCast(e));
return try parser.nodeTextContent(n) orelse "";
@@ -1265,6 +1275,16 @@ pub const HTMLTemplateElement = struct {
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
pub fn get_content(self: *parser.Template, page: *Page) !*parser.DocumentFragment {
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
if (state.template_content) |tc| {
return tc;
}
const tc = try parser.documentCreateDocumentFragment(@ptrCast(page.window.document));
state.template_content = tc;
return tc;
}
};
pub const HTMLTextAreaElement = struct {
@@ -1550,7 +1570,15 @@ test "Browser.HTML.Element" {
.{ "document.activeElement === focused", "true" },
}, .{});
}
test "Browser.HTML.HtmlInputElement.propeties" {
test "Browser.HTML.Element.DataSet" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "<div id=x data-power='over 9000' data-empty data-some-long-key=ok></div>" });
defer runner.deinit();
try runner.testCases(&.{ .{ "let div = document.getElementById('x')", null }, .{ "div.dataset.nope", "undefined" }, .{ "div.dataset.power", "over 9000" }, .{ "div.dataset.empty", "" }, .{ "div.dataset.someLongKey", "ok" }, .{ "delete div.dataset.power", "true" }, .{ "div.dataset.power", "undefined" } }, .{});
}
test "Browser.HTML.HtmlInputElement.properties" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/noslashattheend" });
defer runner.deinit();
var alloc = std.heap.ArenaAllocator.init(runner.app.allocator);
@@ -1634,7 +1662,8 @@ test "Browser.HTML.HtmlInputElement.propeties" {
.{ "input_value.value", "mango" }, // Still mango
}, .{});
}
test "Browser.HTML.HtmlInputElement.propeties.form" {
test "Browser.HTML.HtmlInputElement.properties.form" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html =
\\ <form action="test.php" target="_blank">
\\ <p>
@@ -1652,6 +1681,21 @@ test "Browser.HTML.HtmlInputElement.propeties.form" {
}, .{});
}
test "Browser.HTML.HTMLTemplateElement" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "<div id=c></div>" });
defer runner.deinit();
try runner.testCases(&.{
.{ "let t = document.createElement('template')", null },
.{ "let d = document.createElement('div')", null },
.{ "d.id = 'abc'", null },
.{ "t.content.append(d)", null },
.{ "document.getElementById('abc')", "null" },
.{ "document.getElementById('c').appendChild(t.content.cloneNode(true))", null },
.{ "document.getElementById('abc').id", "abc" },
}, .{});
}
const Check = struct {
input: []const u8,
expected: ?[]const u8 = null, // Needed when input != expected

View File

@@ -0,0 +1,114 @@
// Copyright (C) 2023-2024 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 Env = @import("../env.zig").Env;
const parser = @import("../netsurf.zig");
// https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent
pub const ErrorEvent = struct {
pub const prototype = *parser.Event;
pub const union_make_copy = true;
proto: parser.Event,
message: []const u8,
filename: []const u8,
lineno: i32,
colno: i32,
@"error": ?Env.JsObject,
const ErrorEventInit = struct {
message: []const u8 = "",
filename: []const u8 = "",
lineno: i32 = 0,
colno: i32 = 0,
@"error": ?Env.JsObject = null,
};
pub fn constructor(event_type: []const u8, opts: ?ErrorEventInit) !ErrorEvent {
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, event_type, .{});
try parser.eventSetInternalType(event, .event);
const o = opts orelse ErrorEventInit{};
return .{
.proto = event.*,
.message = o.message,
.filename = o.filename,
.lineno = o.lineno,
.colno = o.colno,
.@"error" = if (o.@"error") |e| try e.persist() else null,
};
}
pub fn get_message(self: *const ErrorEvent) []const u8 {
return self.message;
}
pub fn get_filename(self: *const ErrorEvent) []const u8 {
return self.filename;
}
pub fn get_lineno(self: *const ErrorEvent) i32 {
return self.lineno;
}
pub fn get_colno(self: *const ErrorEvent) i32 {
return self.colno;
}
pub fn get_error(self: *const ErrorEvent) Env.UndefinedOr(Env.JsObject) {
if (self.@"error") |e| {
return .{ .value = e };
}
return .undefined;
}
};
const testing = @import("../../testing.zig");
test "Browser.HTML.ErrorEvent" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "<div id=c></div>" });
defer runner.deinit();
try runner.testCases(&.{
.{ "let e1 = new ErrorEvent('err1')", null },
.{ "e1.message", "" },
.{ "e1.filename", "" },
.{ "e1.lineno", "0" },
.{ "e1.colno", "0" },
.{ "e1.error", "undefined" },
.{
\\ let e2 = new ErrorEvent('err1', {
\\ message: 'm1',
\\ filename: 'fx19',
\\ lineno: 443,
\\ colno: 8999,
\\ error: 'under 9000!',
\\
\\})
,
null,
},
.{ "e2.message", "m1" },
.{ "e2.filename", "fx19" },
.{ "e2.lineno", "443" },
.{ "e2.colno", "8999" },
.{ "e2.error", "under 9000!" },
}, .{});
}

View File

@@ -24,7 +24,6 @@ const Navigator = @import("navigator.zig").Navigator;
const History = @import("history.zig").History;
const Location = @import("location.zig").Location;
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
const Performance = @import("performance.zig").Performance;
pub const Interfaces = .{
HTMLDocument,
@@ -37,5 +36,8 @@ pub const Interfaces = .{
History,
Location,
MediaQueryList,
Performance,
@import("DataSet.zig"),
@import("screen.zig").Interfaces,
@import("error_event.zig").ErrorEvent,
@import("AbortController.zig").Interfaces,
};

View File

@@ -1,87 +0,0 @@
// 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 parser = @import("../netsurf.zig");
const EventTarget = @import("../dom/event_target.zig").EventTarget;
// https://developer.mozilla.org/en-US/docs/Web/API/Performance
pub const Performance = struct {
pub const prototype = *EventTarget;
// Extend libdom event target for pure zig struct.
base: parser.EventTargetTBase = parser.EventTargetTBase{},
time_origin: std.time.Timer,
// if (Window.crossOriginIsolated) -> Resolution in isolated contexts: 5 microseconds
// else -> Resolution in non-isolated contexts: 100 microseconds
const ms_resolution = 100;
fn limitedResolutionMs(nanoseconds: u64) f64 {
const elapsed_at_resolution = ((nanoseconds / std.time.ns_per_us) + ms_resolution / 2) / ms_resolution * ms_resolution;
const elapsed = @as(f64, @floatFromInt(elapsed_at_resolution));
return elapsed / @as(f64, std.time.us_per_ms);
}
pub fn get_timeOrigin(self: *const Performance) f64 {
const is_posix = switch (@import("builtin").os.tag) { // From std.time.zig L125
.windows, .uefi, .wasi => false,
else => true,
};
const zero = std.time.Instant{ .timestamp = if (!is_posix) 0 else .{ .sec = 0, .nsec = 0 } };
const started = self.time_origin.started.since(zero);
return limitedResolutionMs(started);
}
pub fn _now(self: *Performance) f64 {
return limitedResolutionMs(self.time_origin.read());
}
};
const testing = @import("./../../testing.zig");
test "Performance: get_timeOrigin" {
var perf = Performance{ .time_origin = try std.time.Timer.start() };
const time_origin = perf.get_timeOrigin();
try testing.expect(time_origin >= 0);
// Check resolution
try testing.expectDelta(@rem(time_origin * std.time.us_per_ms, 100.0), 0.0, 0.1);
}
test "Performance: now" {
var perf = Performance{ .time_origin = try std.time.Timer.start() };
// Monotonically increasing
var now = perf._now();
while (now <= 0) { // Loop for now to not be 0
try testing.expectEqual(now, 0);
now = perf._now();
}
// Check resolution
try testing.expectDelta(@rem(now * std.time.us_per_ms, 100.0), 0.0, 0.1);
var after = perf._now();
while (after <= now) { // Loop untill after > now
try testing.expectEqual(after, now);
after = perf._now();
}
// Check resolution
try testing.expectDelta(@rem(after * std.time.us_per_ms, 100.0), 0.0, 0.1);
}

109
src/browser/html/screen.zig Normal file
View File

@@ -0,0 +1,109 @@
// Copyright (C) 2023-2024 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 EventTarget = @import("../dom/event_target.zig").EventTarget;
pub const Interfaces = .{
Screen,
ScreenOrientation,
};
// https://developer.mozilla.org/en-US/docs/Web/API/Screen
pub const Screen = struct {
pub const prototype = *EventTarget;
height: u32 = 1080,
width: u32 = 1920,
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/colorDepth
color_depth: u32 = 8,
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/pixelDepth
pixel_depth: u32 = 8,
orientation: ScreenOrientation = .{ .type = .landscape_primary },
pub fn get_availHeight(self: *const Screen) u32 {
return self.height;
}
pub fn get_availWidth(self: *const Screen) u32 {
return self.width;
}
pub fn get_height(self: *const Screen) u32 {
return self.height;
}
pub fn get_width(self: *const Screen) u32 {
return self.width;
}
pub fn get_pixelDepth(self: *const Screen) u32 {
return self.pixel_depth;
}
pub fn get_orientation(self: *const Screen) ScreenOrientation {
return self.orientation;
}
};
const ScreenOrientationType = enum {
portrait_primary,
portrait_secondary,
landscape_primary,
landscape_secondary,
pub fn toString(self: ScreenOrientationType) []const u8 {
return switch (self) {
.portrait_primary => "portrait-primary",
.portrait_secondary => "portrait-secondary",
.landscape_primary => "landscape-primary",
.landscape_secondary => "landscape-secondary",
};
}
};
pub const ScreenOrientation = struct {
pub const prototype = *EventTarget;
angle: u32 = 0,
type: ScreenOrientationType,
pub fn get_angle(self: *const ScreenOrientation) u32 {
return self.angle;
}
pub fn get_type(self: *const ScreenOrientation) []const u8 {
return self.type.toString();
}
};
const testing = @import("../../testing.zig");
test "Browser.HTML.Screen" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let screen = window.screen", "undefined" },
.{ "screen.width === 1920", "true" },
.{ "screen.height === 1080", "true" },
.{ "let orientation = screen.orientation", "undefined" },
.{ "orientation.angle === 0", "true" },
.{ "orientation.type === \"landscape-primary\"", "true" },
}, .{});
}

View File

@@ -31,9 +31,11 @@ const Crypto = @import("../crypto/crypto.zig").Crypto;
const Console = @import("../console/console.zig").Console;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
const Performance = @import("performance.zig").Performance;
const Performance = @import("../dom/performance.zig").Performance;
const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration;
const CustomElementRegistry = @import("../webcomponents/custom_element_registry.zig").CustomElementRegistry;
const Screen = @import("screen.zig").Screen;
const Css = @import("../css/css.zig").Css;
const storage = @import("../storage/storage.zig");
@@ -60,6 +62,8 @@ pub const Window = struct {
navigator: Navigator = .{},
performance: Performance,
custom_elements: CustomElementRegistry = .{},
screen: Screen = .{},
css: Css = .{},
pub fn create(target: ?[]const u8, navigator: ?Navigator) !Window {
var fbs = std.io.fixedBufferStream("");
@@ -169,6 +173,14 @@ pub const Window = struct {
return &self.custom_elements;
}
pub fn get_screen(self: *Window) *Screen {
return &self.screen;
}
pub fn get_CSS(self: *Window) *Css {
return &self.css;
}
pub fn _requestAnimationFrame(self: *Window, cbk: Function, page: *Page) !u32 {
return self.createTimeout(cbk, 5, page, .{ .animation_frame = true });
}
@@ -205,6 +217,21 @@ pub const Window = struct {
};
}
pub fn _btoa(_: *const Window, value: []const u8, page: *Page) ![]const u8 {
const Encoder = std.base64.standard.Encoder;
const out = try page.call_arena.alloc(u8, Encoder.calcSize(value.len));
return Encoder.encode(out, value);
}
pub fn _atob(_: *const Window, value: []const u8, page: *Page) ![]const u8 {
const Decoder = std.base64.standard.Decoder;
const size = Decoder.calcSizeForSlice(value) catch return error.InvalidCharacterError;
const out = try page.call_arena.alloc(u8, size);
Decoder.decode(out, value) catch return error.InvalidCharacterError;
return out;
}
const CreateTimeoutOpts = struct {
repeat: bool = false,
animation_frame: bool = false,
@@ -387,4 +414,27 @@ test "Browser.HTML.Window" {
.{ "window.setTimeout(() => {longCall = true}, 5001);", null },
.{ "longCall;", "false" },
}, .{});
// window event target
try runner.testCases(&.{
.{
\\ let called = false;
\\ window.addEventListener("ready", (e) => {
\\ called = (e.currentTarget == window);
\\ }, {capture: false, once: false});
\\ const evt = new Event("ready", { bubbles: true, cancelable: false });
\\ window.dispatchEvent(evt);
\\ called;
,
"true",
},
}, .{});
try runner.testCases(&.{
.{ "const b64 = btoa('https://ziglang.org/documentation/master/std/#std.base64.Base64Decoder')", "undefined" },
.{ "b64", "aHR0cHM6Ly96aWdsYW5nLm9yZy9kb2N1bWVudGF0aW9uL21hc3Rlci9zdGQvI3N0ZC5iYXNlNjQuQmFzZTY0RGVjb2Rlcg==" },
.{ "const str = atob(b64)", "undefined" },
.{ "str", "https://ziglang.org/documentation/master/std/#std.base64.Base64Decoder" },
.{ "try { atob('b') } catch (e) { e } ", "Error: InvalidCharacterError" },
}, .{});
}

View File

@@ -35,6 +35,8 @@ pub const Mime = struct {
text_html,
text_javascript,
text_plain,
text_css,
application_json,
unknown,
other,
};
@@ -44,6 +46,8 @@ pub const Mime = struct {
text_html: void,
text_javascript: void,
text_plain: void,
text_css: void,
application_json: void,
unknown: void,
other: struct { type: []const u8, sub_type: []const u8 },
};
@@ -174,18 +178,22 @@ pub const Mime = struct {
if (std.meta.stringToEnum(enum {
@"text/xml",
@"text/html",
@"text/css",
@"text/plain",
@"text/javascript",
@"application/javascript",
@"application/x-javascript",
@"text/plain",
@"application/json",
}, type_name)) |known_type| {
const ct: ContentType = switch (known_type) {
.@"text/xml" => .{ .text_xml = {} },
.@"text/html" => .{ .text_html = {} },
.@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} },
.@"text/plain" => .{ .text_plain = {} },
.@"text/css" => .{ .text_css = {} },
.@"application/json" => .{ .application_json = {} },
};
return .{ ct, attribute_start };
}
@@ -218,7 +226,9 @@ pub const Mime = struct {
fn parseAttributeValue(arena: Allocator, value: []const u8) ![]const u8 {
if (value[0] != '"') {
return value;
// almost certainly referenced from an http.Request which has its
// own lifetime.
return arena.dupe(u8, value);
}
// 1 to skip the opening quote
@@ -349,6 +359,9 @@ test "Mime: parse common" {
try expect(.{ .content_type = .{ .text_javascript = {} } }, "text/javascript");
try expect(.{ .content_type = .{ .text_javascript = {} } }, "Application/JavaScript");
try expect(.{ .content_type = .{ .text_javascript = {} } }, "application/x-javascript");
try expect(.{ .content_type = .{ .application_json = {} } }, "application/json");
try expect(.{ .content_type = .{ .text_css = {} } }, "text/css");
}
test "Mime: parse uncommon" {

View File

@@ -525,6 +525,8 @@ pub const EventType = enum(u8) {
progress_event = 1,
custom_event = 2,
mouse_event = 3,
error_event = 4,
abort_signal = 5,
};
pub const MutationEvent = c.dom_mutation_event;
@@ -775,6 +777,17 @@ pub const EventTargetTBase = extern struct {
.add_event_listener = add_event_listener,
.iter_event_listener = iter_event_listener,
},
// When we dispatch the event, we need to provide a target. In reality, the
// target is the container of this EventTargetTBase. But we can't pass that
// to _dom_event_target_dispatch, because it expects a dom_event_target.
// If you try to pass an non-event_target, you'll get weird behavior. For
// example, libdom might dom_node_ref that memory. Say we passed a *Window
// as the target, what happens if libdom calls dom_node_ref(window)? If
// you're lucky, you'll crash. If you're unlucky, you'll increment a random
// part of the window structure.
refcnt: u32 = 0,
eti: c.dom_event_target_internal = c.dom_event_target_internal{ .listeners = null },
pub fn add_event_listener(et: [*c]c.dom_event_target, t: [*c]c.dom_string, l: ?*c.struct_dom_event_listener, capture: bool) callconv(.C) c.dom_exception {

View File

@@ -78,7 +78,10 @@ pub const Page = struct {
renderer: Renderer,
// run v8 micro tasks
microtask_node: Loop.CallbackNode,
// run v8 pump message loop and idle tasks
messageloop_node: Loop.CallbackNode,
keydown_event_node: parser.EventNode,
window_clicked_event_node: parser.EventNode,
@@ -87,13 +90,6 @@ pub const Page = struct {
// execute any JavaScript
main_context: *Env.JsContext,
// List of modules currently fetched/loaded.
module_map: std.StringHashMapUnmanaged([]const u8),
// current_script is the script currently evaluated by the page.
// current_script could by fetch module to resolve module's url to fetch.
current_script: ?*const Script = null,
// indicates intention to navigate to another page on the next loop execution.
delayed_navigation: bool = false,
@@ -113,20 +109,26 @@ pub const Page = struct {
.state_pool = &browser.state_pool,
.cookie_jar = &session.cookie_jar,
.microtask_node = .{ .func = microtaskCallback },
.messageloop_node = .{ .func = messageLoopCallback },
.keydown_event_node = .{ .func = keydownCallback },
.window_clicked_event_node = .{ .func = windowClicked },
.request_factory = browser.http_client.requestFactory(.{
.notification = browser.notification,
}),
.main_context = undefined,
.module_map = .empty,
};
self.main_context = try session.executor.createJsContext(&self.window, self, self, true);
// load polyfills
try polyfill.load(self.arena, self.main_context);
_ = session.executor.env.snapshot(self.main_context);
_ = try session.browser.app.loop.timeout(1 * std.time.ns_per_ms, &self.microtask_node);
// message loop must run only non-test env
if (comptime !builtin.is_test) {
_ = try session.browser.app.loop.timeout(1 * std.time.ns_per_ms, &self.messageloop_node);
}
}
fn microtaskCallback(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
@@ -135,6 +137,12 @@ pub const Page = struct {
repeat_delay.* = 1 * std.time.ns_per_ms;
}
fn messageLoopCallback(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
const self: *Page = @fieldParentPtr("messageloop_node", node);
self.session.browser.runMessageLoop();
repeat_delay.* = 100 * std.time.ns_per_ms;
}
// dump writes the page content into the given file.
pub fn dump(self: *const Page, out: std.fs.File) !void {
if (self.raw_data) |raw_data| {
@@ -147,29 +155,17 @@ pub const Page = struct {
try Dump.writeHTML(doc, out);
}
pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) !?[]const u8 {
pub fn fetchModuleSource(ctx: *anyopaque, src: []const u8) !?[]const u8 {
const self: *Page = @ptrCast(@alignCast(ctx));
const base = if (self.current_script) |s| s.src else null;
const file_src = blk: {
if (base) |_base| {
break :blk try URL.stitch(self.arena, specifier, _base, .{});
} else break :blk specifier;
};
if (self.module_map.get(file_src)) |module| return module;
const module = try self.fetchData(specifier, base);
if (module) |_module| try self.module_map.putNoClobber(self.arena, file_src, _module);
return module;
return self.fetchData("module", src);
}
pub fn wait(self: *Page) !void {
pub fn wait(self: *Page, wait_ns: usize) !void {
var try_catch: Env.TryCatch = undefined;
try_catch.init(self.main_context);
defer try_catch.deinit();
try self.session.browser.app.loop.run();
try self.session.browser.app.loop.run(wait_ns);
if (try_catch.hasCaught() == false) {
log.debug(.browser, "page wait complete", .{});
@@ -217,7 +213,7 @@ pub const Page = struct {
{
// block exists to limit the lifetime of the request, which holds
// onto a connection
var request = try self.newHTTPRequest(opts.method, &self.url, .{ .navigation = true });
var request = try self.newHTTPRequest(opts.method, &self.url, .{ .navigation = true, .is_http = true });
defer request.deinit();
request.body = opts.body;
@@ -258,17 +254,33 @@ pub const Page = struct {
.reason = opts.reason,
});
if (!mime.isHTML()) {
if (mime.isHTML()) {
// the page is an HTML, load it as it.
try self.loadHTMLDoc(&response, mime.charset orelse "utf-8");
} else {
// the page isn't an HTML
var arr: std.ArrayListUnmanaged(u8) = .{};
while (try response.next()) |data| {
try arr.appendSlice(arena, try arena.dupe(u8, data));
}
// save the body into the page.
self.raw_data = arr.items;
return;
}
try self.loadHTMLDoc(&response, mime.charset orelse "utf-8");
// construct a pseudo HTML containing the response body.
var buf: std.ArrayListUnmanaged(u8) = .{};
switch (mime.content_type) {
.application_json, .text_plain, .text_javascript, .text_css => {
try buf.appendSlice(arena, "<html><head><meta charset=\"utf-8\"></head><body><pre>");
try buf.appendSlice(arena, self.raw_data.?);
try buf.appendSlice(arena, "</pre></body></html>\n");
},
// In other cases, we prefer to not integrate the content into the HTML document page iself.
else => {},
}
var fbs = std.io.fixedBufferStream(buf.items);
try self.loadHTMLDoc(fbs.reader(), mime.charset orelse "utf-8");
}
}
try self.processHTMLDoc();
@@ -460,26 +472,20 @@ pub const Page = struct {
log.err(.browser, "clear document script", .{ .err = err });
};
var script_source: ?[]const u8 = null;
if (script.src) |src| {
self.current_script = script;
defer self.current_script = null;
const src = script.src orelse {
// source is inline
// TODO handle charset attribute
const script_source = try parser.nodeTextContent(parser.elementToNode(script.element)) orelse return;
return script.eval(self, script_source);
};
// https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-classic-script
script_source = (try self.fetchData(src, null)) orelse {
const script_source = (try self.fetchData("script", src)) orelse {
// TODO If el's result is null, then fire an event named error at
// el, and return
return;
};
} else {
// source is inline
// TODO handle charset attribute
script_source = try parser.nodeTextContent(parser.elementToNode(script.element));
}
if (script_source) |ss| {
try script.eval(self, ss);
}
return script.eval(self, script_source);
// TODO If el's from an external file is true, then fire an event
// named load at el.
@@ -489,7 +495,11 @@ pub const Page = struct {
// It resolves src using the page's uri.
// If a base path is given, src is resolved according to the base first.
// the caller owns the returned string
fn fetchData(self: *const Page, src: []const u8, base: ?[]const u8) !?[]const u8 {
fn fetchData(
self: *const Page,
comptime reason: []const u8,
src: []const u8,
) !?[]const u8 {
const arena = self.arena;
// Handle data URIs.
@@ -497,22 +507,27 @@ pub const Page = struct {
return data_uri.data;
}
var res_src = src;
// if a base path is given, we resolve src using base.
if (base) |_base| {
res_src = try URL.stitch(arena, src, _base, .{ .alloc = .if_needed });
}
var origin_url = &self.url;
const url = try origin_url.resolve(arena, res_src);
const url = try origin_url.resolve(arena, src);
log.debug(.http, "fetching script", .{ .url = url });
errdefer |err| log.err(.http, "fetch error", .{ .err = err, .url = url });
var status_code: u16 = 0;
log.debug(.http, "fetching script", .{
.url = url,
.src = src,
.reason = reason,
});
errdefer |err| log.err(.http, "fetch error", .{
.err = err,
.url = url,
.reason = reason,
.status = status_code,
});
var request = try self.newHTTPRequest(.GET, &url, .{
.origin_uri = &origin_url.uri,
.navigation = false,
.is_http = true,
});
defer request.deinit();
@@ -520,7 +535,8 @@ pub const Page = struct {
var header = response.header;
try self.session.cookie_jar.populateFromResponse(&url.uri, &header);
if (header.status < 200 or header.status > 299) {
status_code = header.status;
if (status_code < 200 or status_code > 299) {
return error.BadStatusCode;
}
@@ -538,7 +554,8 @@ pub const Page = struct {
log.info(.http, "fetch complete", .{
.url = url,
.status = header.status,
.reason = reason,
.status = status_code,
.content_length = arr.items.len,
});
return arr.items;
@@ -1000,22 +1017,27 @@ const Script = struct {
try_catch.init(page.main_context);
defer try_catch.deinit();
const src = self.src orelse "inline";
_ = switch (self.kind) {
.javascript => page.main_context.exec(body, src),
.module => blk: {
switch (try page.main_context.module(body, src)) {
.value => |v| break :blk v,
.exception => |e| {
log.warn(.user_script, "eval module", .{
const src: []const u8 = blk: {
const s = self.src orelse break :blk page.url.raw;
break :blk try URL.stitch(page.arena, s, page.url.raw, .{.alloc = .if_needed});
};
// if self.src is null, then this is an inline script, and it should
// not be cached.
const cacheable = self.src != null;
log.debug(.browser, "executing script", .{
.src = src,
.err = try e.exception(page.arena),
.kind = self.kind,
.cacheable = cacheable,
});
return error.JsErr;
},
}
},
} catch {
const result = switch (self.kind) {
.javascript => page.main_context.eval(body, src),
.module => page.main_context.module(body, src, cacheable),
};
result catch {
if (page.delayed_navigation) {
return error.Terminated;
}
@@ -1024,6 +1046,7 @@ const Script = struct {
log.warn(.user_script, "eval script", .{
.src = src,
.err = msg,
.cacheable = cacheable,
});
}

View File

@@ -62,20 +62,38 @@ const FlatRenderer = struct {
gop.value_ptr.* = x;
}
const _x: f64 = @floatFromInt(x);
const y: f64 = 0.0;
const w: f64 = 1.0;
const h: f64 = 1.0;
return .{
.x = @floatFromInt(x),
.y = 0.0,
.width = 1.0,
.height = 1.0,
.x = _x,
.y = y,
.width = w,
.height = h,
.left = _x,
.top = y,
.right = _x + w,
.bottom = y + h,
};
}
pub fn boundingRect(self: *const FlatRenderer) Element.DOMRect {
const x: f64 = 0.0;
const y: f64 = 0.0;
const w: f64 = @floatFromInt(self.width());
const h: f64 = @floatFromInt(self.width());
return .{
.x = 0.0,
.y = 0.0,
.width = @floatFromInt(self.width()),
.height = @floatFromInt(self.height()),
.x = x,
.y = y,
.width = w,
.height = h,
.left = x,
.top = y,
.right = x + w,
.bottom = y + h,
};
}

View File

@@ -12,6 +12,7 @@ pub const LookupOpts = struct {
request_time: ?i64 = null,
origin_uri: ?*const Uri = null,
navigation: bool = true,
is_http: bool,
};
pub const Jar = struct {
@@ -32,6 +33,13 @@ pub const Jar = struct {
self.cookies.deinit(self.allocator);
}
pub fn clearRetainingCapacity(self: *Jar) void {
for (self.cookies.items) |c| {
c.deinit();
}
self.cookies.clearRetainingCapacity();
}
pub fn add(
self: *Jar,
cookie: Cookie,
@@ -59,87 +67,33 @@ pub const Jar = struct {
}
}
pub fn removeExpired(self: *Jar, request_time: ?i64) void {
if (self.cookies.items.len == 0) return;
const time = request_time orelse std.time.timestamp();
var i: usize = self.cookies.items.len - 1;
while (i > 0) {
defer i -= 1;
const cookie = &self.cookies.items[i];
if (isCookieExpired(cookie, time)) {
self.cookies.swapRemove(i).deinit();
}
}
}
pub fn forRequest(self: *Jar, target_uri: *const Uri, writer: anytype, opts: LookupOpts) !void {
const target_path = target_uri.path.percent_encoded;
const target_host = (target_uri.host orelse return error.InvalidURI).percent_encoded;
const target = PreparedUri{
.host = (target_uri.host orelse return error.InvalidURI).percent_encoded,
.path = target_uri.path.percent_encoded,
.secure = std.mem.eql(u8, target_uri.scheme, "https"),
};
const same_site = try areSameSite(opts.origin_uri, target.host);
const same_site = try areSameSite(opts.origin_uri, target_host);
const is_secure = std.mem.eql(u8, target_uri.scheme, "https");
var i: usize = 0;
var cookies = self.cookies.items;
const navigation = opts.navigation;
const request_time = opts.request_time orelse std.time.timestamp();
removeExpired(self, opts.request_time);
var first = true;
while (i < cookies.len) {
const cookie = &cookies[i];
for (self.cookies.items) |*cookie| {
if (!cookie.appliesTo(&target, same_site, opts.navigation, opts.is_http)) continue;
if (isCookieExpired(cookie, request_time)) {
cookie.deinit();
_ = self.cookies.swapRemove(i);
// don't increment i !
continue;
}
i += 1;
if (is_secure == false and cookie.secure) {
// secure cookie can only be sent over HTTPs
continue;
}
if (same_site == false) {
// If we aren't on the "same site" (matching 2nd level domain
// taking into account public suffix list), then the cookie
// can only be sent if cookie.same_site == .none, or if
// we're navigating to (as opposed to, say, loading an image)
// and cookie.same_site == .lax
switch (cookie.same_site) {
.strict => continue,
.lax => if (navigation == false) continue,
.none => {},
}
}
{
const domain = cookie.domain;
if (domain[0] == '.') {
// When a Set-Cookie header has a Domain attribute
// Then we will _always_ prefix it with a dot, extending its
// availability to all subdomains (yes, setting the Domain
// attributes EXPANDS the domains which the cookie will be
// sent to, to always include all subdomains).
if (std.mem.eql(u8, target_host, domain[1..]) == false and std.mem.endsWith(u8, target_host, domain) == false) {
continue;
}
} else if (std.mem.eql(u8, target_host, domain) == false) {
// When the Domain attribute isn't specific, then the cookie
// is only sent on an exact match.
continue;
}
}
{
const path = cookie.path;
if (path[path.len - 1] == '/') {
// If our cookie has a trailing slash, we can only match is
// the target path is a perfix. I.e., if our path is
// /doc/ we can only match /doc/*
if (std.mem.startsWith(u8, target_path, path) == false) {
continue;
}
} else {
// Our cookie path is something like /hello
if (std.mem.startsWith(u8, target_path, path) == false) {
// The target path has to either be /hello (it isn't)
continue;
} else if (target_path.len < path.len or (target_path.len > path.len and target_path[path.len] != '/')) {
// Or it has to be something like /hello/* (it isn't)
// it isn't!
continue;
}
}
}
// we have a match!
if (first) {
first = false;
@@ -173,47 +127,9 @@ pub const Jar = struct {
}
};
pub const CookieList = struct {
_cookies: std.ArrayListUnmanaged(*const Cookie) = .{},
pub fn deinit(self: *CookieList, allocator: Allocator) void {
self._cookies.deinit(allocator);
}
pub fn cookies(self: *const CookieList) []*const Cookie {
return self._cookies.items;
}
pub fn len(self: *const CookieList) usize {
return self._cookies.items.len;
}
pub fn write(self: *const CookieList, writer: anytype) !void {
const all = self._cookies.items;
if (all.len == 0) {
return;
}
try writeCookie(all[0], writer);
for (all[1..]) |cookie| {
try writer.writeAll("; ");
try writeCookie(cookie, writer);
}
}
fn writeCookie(cookie: *const Cookie, writer: anytype) !void {
if (cookie.name.len > 0) {
try writer.writeAll(cookie.name);
try writer.writeByte('=');
}
if (cookie.value.len > 0) {
try writer.writeAll(cookie.value);
}
}
};
fn isCookieExpired(cookie: *const Cookie, now: i64) bool {
const ce = cookie.expires orelse return false;
return ce <= now;
return ce <= @as(f64, @floatFromInt(now));
}
fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool {
@@ -256,12 +172,12 @@ pub const Cookie = struct {
arena: ArenaAllocator,
name: []const u8,
value: []const u8,
path: []const u8,
domain: []const u8,
expires: ?i64,
secure: bool,
http_only: bool,
same_site: SameSite,
path: []const u8,
expires: ?f64,
secure: bool = false,
http_only: bool = false,
same_site: SameSite = .none,
const SameSite = enum {
strict,
@@ -292,9 +208,6 @@ pub const Cookie = struct {
// this check is necessary, `std.mem.minMax` asserts len > 0
return error.Empty;
}
const host = (uri.host orelse return error.InvalidURI).percent_encoded;
{
const min, const max = std.mem.minMax(u8, str);
if (min < 32 or max > 126) {
@@ -313,7 +226,7 @@ pub const Cookie = struct {
var secure: ?bool = null;
var max_age: ?i64 = null;
var http_only: ?bool = null;
var expires: ?DateTime = null;
var expires: ?[]const u8 = null;
var same_site: ?Cookie.SameSite = null;
var it = std.mem.splitScalar(u8, rest, ';');
@@ -339,37 +252,13 @@ pub const Cookie = struct {
samesite,
}, std.ascii.lowerString(&scrap, key_string)) orelse continue;
var value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]);
const value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]);
switch (key) {
.path => {
// path attribute value either begins with a '/' or we
// ignore it and use the "default-path" algorithm
if (value.len > 0 and value[0] == '/') {
path = value;
}
},
.domain => {
if (value.len == 0) {
continue;
}
if (value[0] == '.') {
// leading dot is ignored
value = value[1..];
}
if (std.mem.indexOfScalarPos(u8, value, 0, '.') == null and std.ascii.eqlIgnoreCase("localhost", value) == false) {
// can't set a cookie for a TLD
return error.InvalidDomain;
}
if (std.mem.endsWith(u8, host, value) == false) {
return error.InvalidDomain;
}
domain = value;
},
.path => path = value,
.domain => domain = value,
.secure => secure = true,
.@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue,
.expires => expires = DateTime.parse(value, .rfc822) catch continue,
.expires => expires = value,
.httponly => http_only = true,
.samesite => {
same_site = std.meta.stringToEnum(Cookie.SameSite, std.ascii.lowerString(&scrap, value)) orelse continue;
@@ -386,27 +275,33 @@ pub const Cookie = struct {
const aa = arena.allocator();
const owned_name = try aa.dupe(u8, cookie_name);
const owned_value = try aa.dupe(u8, cookie_value);
const owned_path = if (path) |p|
try aa.dupe(u8, p)
else
try defaultPath(aa, uri.path.percent_encoded);
const owned_path = try parsePath(aa, uri, path);
const owned_domain = try parseDomain(aa, uri, domain);
const owned_domain = if (domain) |d| blk: {
const s = try aa.alloc(u8, d.len + 1);
s[0] = '.';
@memcpy(s[1..], d);
break :blk s;
} else blk: {
break :blk try aa.dupe(u8, host);
};
var normalized_expires: ?i64 = null;
var normalized_expires: ?f64 = null;
if (max_age) |ma| {
normalized_expires = std.time.timestamp() + ma;
normalized_expires = @floatFromInt(std.time.timestamp() + ma);
} else {
// max age takes priority over expires
if (expires) |e| {
normalized_expires = e.sub(DateTime.now(), .seconds);
if (expires) |expires_| {
var exp_dt = DateTime.parse(expires_, .rfc822) catch null;
if (exp_dt == null) {
if ((expires_.len > 11 and expires_[7] == '-' and expires_[11] == '-')) {
// Replace dashes and try again
const output = try aa.dupe(u8, expires_);
output[7] = ' ';
output[11] = ' ';
exp_dt = DateTime.parse(output, .rfc822) catch null;
}
}
if (exp_dt) |dt| {
normalized_expires = @floatFromInt(dt.unix(.seconds));
} else {
// Algolia, for example, will call document.setCookie with
// an expired value which is literally 'Invalid Date'
// (it's trying to do something like: `new Date() + undefined`).
log.debug(.web_api, "cookie expires date", .{ .date = expires_ });
}
}
}
@@ -423,6 +318,100 @@ pub const Cookie = struct {
};
}
pub fn parsePath(arena: Allocator, uri: ?*const std.Uri, explicit_path: ?[]const u8) ![]const u8 {
// path attribute value either begins with a '/' or we
// ignore it and use the "default-path" algorithm
if (explicit_path) |path| {
if (path.len > 0 and path[0] == '/') {
return try arena.dupe(u8, path);
}
}
// default-path
const url_path = (uri orelse return "/").path;
const either = url_path.percent_encoded;
if (either.len == 0 or (either.len == 1 and either[0] == '/')) {
return "/";
}
var owned_path: []const u8 = try percentEncode(arena, url_path, isPathChar);
const last = std.mem.lastIndexOfScalar(u8, owned_path[1..], '/') orelse {
return "/";
};
return try arena.dupe(u8, owned_path[0 .. last + 1]);
}
pub fn parseDomain(arena: Allocator, uri: ?*const std.Uri, explicit_domain: ?[]const u8) ![]const u8 {
var encoded_host: ?[]const u8 = null;
if (uri) |uri_| {
const uri_host = uri_.host orelse return error.InvalidURI;
const host = try percentEncode(arena, uri_host, isHostChar);
_ = toLower(host);
encoded_host = host;
}
if (explicit_domain) |domain| {
if (domain.len > 0) {
const no_leading_dot = if (domain[0] == '.') domain[1..] else domain;
var list = std.ArrayList(u8).init(arena);
try list.ensureTotalCapacity(no_leading_dot.len + 1); // Expect no precents needed
list.appendAssumeCapacity('.');
try std.Uri.Component.percentEncode(list.writer(), no_leading_dot, isHostChar);
var owned_domain: []u8 = list.items; // @memory retains memory used before growing
_ = toLower(owned_domain);
if (std.mem.indexOfScalarPos(u8, owned_domain, 1, '.') == null and std.mem.eql(u8, "localhost", owned_domain[1..]) == false) {
// can't set a cookie for a TLD
return error.InvalidDomain;
}
if (encoded_host) |host| {
if (std.mem.endsWith(u8, host, owned_domain[1..]) == false) {
return error.InvalidDomain;
}
}
return owned_domain;
}
}
return encoded_host orelse return error.InvalidDomain; // default-domain
}
pub fn percentEncode(arena: Allocator, component: std.Uri.Component, comptime isValidChar: fn (u8) bool) ![]u8 {
switch (component) {
.raw => |str| {
var list = std.ArrayList(u8).init(arena);
try list.ensureTotalCapacity(str.len); // Expect no precents needed
try std.Uri.Component.percentEncode(list.writer(), str, isValidChar);
return list.items; // @memory retains memory used before growing
},
.percent_encoded => |str| {
return try arena.dupe(u8, str);
},
}
}
pub fn isHostChar(c: u8) bool {
return switch (c) {
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true,
':' => true,
'[', ']' => true,
else => false,
};
}
pub fn isPathChar(c: u8) bool {
return switch (c) {
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true,
'/', ':', '@' => true,
else => false,
};
}
fn parseNameValue(str: []const u8) !struct { []const u8, []const u8, []const u8 } {
const key_value_end = std.mem.indexOfScalarPos(u8, str, 0, ';') orelse str.len;
const rest = if (key_value_end == str.len) "" else str[key_value_end + 1 ..];
@@ -439,17 +428,77 @@ pub const Cookie = struct {
const value = trim(str[sep + 1 .. key_value_end]);
return .{ name, value, rest };
}
pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, navigation: bool, is_http: bool) bool {
if (self.http_only and is_http == false) {
// http only cookies can be accessed from Javascript
return false;
}
if (url.secure == false and self.secure) {
// secure cookie can only be sent over HTTPs
return false;
}
if (same_site == false) {
// If we aren't on the "same site" (matching 2nd level domain
// taking into account public suffix list), then the cookie
// can only be sent if cookie.same_site == .none, or if
// we're navigating to (as opposed to, say, loading an image)
// and cookie.same_site == .lax
switch (self.same_site) {
.strict => return false,
.lax => if (navigation == false) return false,
.none => {},
}
}
{
if (self.domain[0] == '.') {
// When a Set-Cookie header has a Domain attribute
// Then we will _always_ prefix it with a dot, extending its
// availability to all subdomains (yes, setting the Domain
// attributes EXPANDS the domains which the cookie will be
// sent to, to always include all subdomains).
if (std.mem.eql(u8, url.host, self.domain[1..]) == false and std.mem.endsWith(u8, url.host, self.domain) == false) {
return false;
}
} else if (std.mem.eql(u8, url.host, self.domain) == false) {
// When the Domain attribute isn't specific, then the cookie
// is only sent on an exact match.
return false;
}
}
{
if (self.path[self.path.len - 1] == '/') {
// If our cookie has a trailing slash, we can only match is
// the target path is a perfix. I.e., if our path is
// /doc/ we can only match /doc/*
if (std.mem.startsWith(u8, url.path, self.path) == false) {
return false;
}
} else {
// Our cookie path is something like /hello
if (std.mem.startsWith(u8, url.path, self.path) == false) {
// The target path has to either be /hello (it isn't)
return false;
} else if (url.path.len < self.path.len or (url.path.len > self.path.len and url.path[self.path.len] != '/')) {
// Or it has to be something like /hello/* (it isn't)
// it isn't!
return false;
}
}
}
return true;
}
};
fn defaultPath(allocator: Allocator, document_path: []const u8) ![]const u8 {
if (document_path.len == 0 or (document_path.len == 1 and document_path[0] == '/')) {
return "/";
}
const last = std.mem.lastIndexOfScalar(u8, document_path[1..], '/') orelse {
return "/";
};
return try allocator.dupe(u8, document_path[0 .. last + 1]);
}
pub const PreparedUri = struct {
host: []const u8, // Percent encoded, lower case
path: []const u8, // Percent encoded
secure: bool, // True if scheme is https
};
fn trim(str: []const u8) []const u8 {
return std.mem.trim(u8, str, &std.ascii.whitespace);
@@ -463,6 +512,13 @@ fn trimRight(str: []const u8) []const u8 {
return std.mem.trimLeft(u8, str, &std.ascii.whitespace);
}
pub fn toLower(str: []u8) []u8 {
for (str, 0..) |c, i| {
str[i] = std.ascii.toLower(c);
}
return str;
}
const testing = @import("../../testing.zig");
test "cookie: findSecondLevelDomain" {
const cases = [_]struct { []const u8, []const u8 }{
@@ -548,7 +604,7 @@ test "Jar: forRequest" {
{
// test with no cookies
try expectCookies("", &jar, test_uri, .{});
try expectCookies("", &jar, test_uri, .{ .is_http = true });
}
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "global1=1"), now);
@@ -562,97 +618,114 @@ test "Jar: forRequest" {
try jar.add(try Cookie.parse(testing.allocator, &test_uri_2, "domain1=9;domain=test.lightpanda.io"), now);
// nothing fancy here
try expectCookies("global1=1; global2=2", &jar, test_uri, .{});
try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .origin_uri = &test_uri, .navigation = false });
try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .is_http = true });
try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .origin_uri = &test_uri, .navigation = false, .is_http = true });
// We have a cookie where Domain=lightpanda.io
// This should _not_ match xyxlightpanda.io
try expectCookies("", &jar, try std.Uri.parse("http://anothersitelightpanda.io/"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// matching path without trailing /
try expectCookies("global1=1; global2=2; path1=3", &jar, try std.Uri.parse("http://lightpanda.io/about"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// incomplete prefix path
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/abou"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// path doesn't match
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/aboutus"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// path doesn't match cookie directory
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/docs"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// exact directory match
try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// sub directory match
try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/more"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// secure
try expectCookies("global1=1; global2=2; secure=5", &jar, try std.Uri.parse("https://lightpanda.io/"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// navigational cross domain, secure
try expectCookies("global1=1; global2=2; secure=5; sitenone=6; sitelax=7", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{
.origin_uri = &(try std.Uri.parse("https://example.com/")),
.is_http = true,
});
// navigational cross domain, insecure
try expectCookies("global1=1; global2=2; sitelax=7", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{
.origin_uri = &(try std.Uri.parse("https://example.com/")),
.is_http = true,
});
// non-navigational cross domain, insecure
try expectCookies("", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{
.origin_uri = &(try std.Uri.parse("https://example.com/")),
.navigation = false,
.is_http = true,
});
// non-navigational cross domain, secure
try expectCookies("sitenone=6", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{
.origin_uri = &(try std.Uri.parse("https://example.com/")),
.navigation = false,
.is_http = true,
});
// non-navigational same origin
try expectCookies("global1=1; global2=2; sitelax=7; sitestrict=8", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{
.origin_uri = &(try std.Uri.parse("https://lightpanda.io/")),
.navigation = false,
.is_http = true,
});
// exact domain match + suffix
try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://test.lightpanda.io/"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// domain suffix match + suffix
try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://1.test.lightpanda.io/"), .{
.origin_uri = &test_uri,
.is_http = true,
});
// non-matching domain
try expectCookies("global2=2", &jar, try std.Uri.parse("http://other.lightpanda.io/"), .{
.origin_uri = &test_uri,
.is_http = true,
});
const l = jar.cookies.items.len;
try expectCookies("global1=1", &jar, test_uri, .{
.request_time = now + 100,
.origin_uri = &test_uri,
.is_http = true,
});
try testing.expectEqual(l - 1, jar.cookies.items.len);
@@ -660,40 +733,6 @@ test "Jar: forRequest" {
// the 'global2' cookie
}
test "CookieList: write" {
var arr: std.ArrayListUnmanaged(u8) = .{};
defer arr.deinit(testing.allocator);
var cookie_list = CookieList{};
defer cookie_list.deinit(testing.allocator);
const c1 = try Cookie.parse(testing.allocator, &test_uri, "cookie_name=cookie_value");
defer c1.deinit();
{
try cookie_list._cookies.append(testing.allocator, &c1);
try cookie_list.write(arr.writer(testing.allocator));
try testing.expectEqual("cookie_name=cookie_value", arr.items);
}
const c2 = try Cookie.parse(testing.allocator, &test_uri, "x84");
defer c2.deinit();
{
arr.clearRetainingCapacity();
try cookie_list._cookies.append(testing.allocator, &c2);
try cookie_list.write(arr.writer(testing.allocator));
try testing.expectEqual("cookie_name=cookie_value; x84", arr.items);
}
const c3 = try Cookie.parse(testing.allocator, &test_uri, "nope=");
defer c3.deinit();
{
arr.clearRetainingCapacity();
try cookie_list._cookies.append(testing.allocator, &c3);
try cookie_list.write(arr.writer(testing.allocator));
try testing.expectEqual("cookie_name=cookie_value; x84; nope=", arr.items);
}
}
test "Cookie: parse key=value" {
try expectError(error.Empty, null, "");
try expectError(error.InvalidByteSequence, null, &.{ 'a', 30, '=', 'b' });
@@ -816,7 +855,8 @@ test "Cookie: parse expires" {
try expectAttribute(.{ .expires = null }, null, "b;expires=13.22");
try expectAttribute(.{ .expires = null }, null, "b;expires=33");
try expectAttribute(.{ .expires = 1918798080 - std.time.timestamp() }, null, "b;expires=Wed, 21 Oct 2030 07:28:00 GMT");
try expectAttribute(.{ .expires = 1918798080 }, null, "b;expires=Wed, 21 Oct 2030 07:28:00 GMT");
try expectAttribute(.{ .expires = 1784275395 }, null, "b;expires=Fri, 17-Jul-2026 08:03:15 GMT");
// max-age has priority over expires
try expectAttribute(.{ .expires = std.time.timestamp() + 10 }, null, "b;Max-Age=10; expires=Wed, 21 Oct 2030 07:28:00 GMT");
}
@@ -836,7 +876,7 @@ test "Cookie: parse all" {
.http_only = true,
.secure = true,
.domain = ".lightpanda.io",
.expires = std.time.timestamp() + 30,
.expires = @floatFromInt(std.time.timestamp() + 30),
}, "https://lightpanda.io/cms/users", "user-id=9000; HttpOnly; Max-Age=30; Secure; path=/; Domain=lightpanda.io");
try expectCookie(.{
@@ -847,7 +887,7 @@ test "Cookie: parse all" {
.secure = false,
.domain = ".localhost",
.same_site = .lax,
.expires = std.time.timestamp() + 7200,
.expires = @floatFromInt(std.time.timestamp() + 7200),
}, "http://localhost:8000/login", "app_session=123; Max-Age=7200; path=/; domain=localhost; httponly; samesite=lax");
}
@@ -874,7 +914,7 @@ const ExpectedCookie = struct {
value: []const u8,
path: []const u8,
domain: []const u8,
expires: ?i64 = null,
expires: ?f64 = null,
secure: bool = false,
http_only: bool = false,
same_site: Cookie.SameSite = .lax,
@@ -893,7 +933,7 @@ fn expectCookie(expected: ExpectedCookie, url: []const u8, set_cookie: []const u
try testing.expectEqual(expected.path, cookie.path);
try testing.expectEqual(expected.domain, cookie.domain);
try testing.expectDelta(expected.expires, cookie.expires, 2);
try testing.expectDelta(expected.expires, cookie.expires, 2.0);
}
fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8) !void {
@@ -903,7 +943,10 @@ fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8)
inline for (@typeInfo(@TypeOf(expected)).@"struct".fields) |f| {
if (comptime std.mem.eql(u8, f.name, "expires")) {
try testing.expectDelta(expected.expires, cookie.expires, 1);
switch (@typeInfo(@TypeOf(expected.expires))) {
.int, .comptime_int => try testing.expectDelta(@as(f64, @floatFromInt(expected.expires)), cookie.expires, 1.0),
else => try testing.expectDelta(expected.expires, cookie.expires, 1.0),
}
} else {
try testing.expectEqual(@field(expected, f.name), @field(cookie, f.name));
}

View File

@@ -37,10 +37,10 @@ pub const ProgressEvent = struct {
loaded: u64 = 0,
total: u64 = 0,
pub fn constructor(eventType: []const u8, opts: ?EventInit) !ProgressEvent {
pub fn constructor(event_type: []const u8, opts: ?EventInit) !ProgressEvent {
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, eventType, .{});
try parser.eventInit(event, event_type, .{});
try parser.eventSetInternalType(event, .progress_event);
const o = opts orelse EventInit{};

View File

@@ -458,7 +458,6 @@ pub const XMLHttpRequest = struct {
&self.url.?.uri,
self,
onHttpRequestReady,
self.loop,
);
}
@@ -475,6 +474,7 @@ pub const XMLHttpRequest = struct {
try self.cookie_jar.forRequest(&self.url.?.uri, arr.writer(self.arena), .{
.navigation = false,
.origin_uri = &self.origin_url.uri,
.is_http = true,
});
if (arr.items.len > 0) {
@@ -493,7 +493,7 @@ pub const XMLHttpRequest = struct {
}
}
try request.sendAsync(self.loop, self, .{});
try request.sendAsync(self, .{});
self.request = request;
}

View File

@@ -203,6 +203,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command),
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
else => {},
},
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
@@ -319,6 +320,7 @@ pub fn BrowserContext(comptime CDP_T: type) type {
inspector: Inspector,
isolated_world: ?IsolatedWorld,
http_proxy_before: ??std.Uri = null,
const Self = @This();
@@ -373,6 +375,8 @@ pub fn BrowserContext(comptime CDP_T: type) type {
self.node_registry.deinit();
self.node_search_list.deinit();
self.cdp.browser.notification.unregisterAll(self);
if (self.http_proxy_before) |prev_proxy| self.cdp.browser.http_client.http_proxy = prev_proxy;
}
pub fn reset(self: *Self) void {

View File

@@ -17,10 +17,11 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Notification = @import("../../notification.zig").Notification;
const log = @import("../../log.zig");
const Allocator = std.mem.Allocator;
const CdpStorage = @import("storage.zig");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
@@ -28,6 +29,11 @@ pub fn processMessage(cmd: anytype) !void {
disable,
setCacheDisabled,
setExtraHTTPHeaders,
deleteCookies,
clearBrowserCookies,
setCookie,
setCookies,
getCookies,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
@@ -35,6 +41,11 @@ pub fn processMessage(cmd: anytype) !void {
.disable => return disable(cmd),
.setCacheDisabled => return cmd.sendResult(null, .{}),
.setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd),
.deleteCookies => return deleteCookies(cmd),
.clearBrowserCookies => return clearBrowserCookies(cmd),
.setCookie => return setCookie(cmd),
.setCookies => return setCookies(cmd),
.getCookies => return getCookies(cmd),
}
}
@@ -71,6 +82,112 @@ fn setExtraHTTPHeaders(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}
const Cookie = @import("../../browser/storage/storage.zig").Cookie;
// Only matches the cookie on provided parameters
fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, path: ?[]const u8) bool {
if (!std.mem.eql(u8, cookie.name, name)) return false;
if (domain) |domain_| {
const c_no_dot = if (std.mem.startsWith(u8, cookie.domain, ".")) cookie.domain[1..] else cookie.domain;
const d_no_dot = if (std.mem.startsWith(u8, domain_, ".")) domain_[1..] else domain_;
if (!std.mem.eql(u8, c_no_dot, d_no_dot)) return false;
}
if (path) |path_| {
if (!std.mem.eql(u8, cookie.path, path_)) return false;
}
return true;
}
fn deleteCookies(cmd: anytype) !void {
const params = (try cmd.params(struct {
name: []const u8,
url: ?[]const u8 = null,
domain: ?[]const u8 = null,
path: ?[]const u8 = null,
partitionKey: ?CdpStorage.CookiePartitionKey = null,
})) orelse return error.InvalidParams;
if (params.partitionKey != null) return error.NotYetImplementedParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const cookies = &bc.session.cookie_jar.cookies;
const uri = if (params.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null;
const uri_ptr = if (uri) |u| &u else null;
var index = cookies.items.len;
while (index > 0) {
index -= 1;
const cookie = &cookies.items[index];
const domain = try Cookie.parseDomain(cmd.arena, uri_ptr, params.domain);
const path = try Cookie.parsePath(cmd.arena, uri_ptr, params.path);
// We do not want to use Cookie.appliesTo here. As a Cookie with a shorter path would match.
// Similar to deduplicating with areCookiesEqual, except domain and path are optional.
if (cookieMatches(cookie, params.name, domain, path)) {
cookies.swapRemove(index).deinit();
}
}
return cmd.sendResult(null, .{});
}
fn clearBrowserCookies(cmd: anytype) !void {
if (try cmd.params(struct {}) != null) return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
bc.session.cookie_jar.clearRetainingCapacity();
return cmd.sendResult(null, .{});
}
fn setCookie(cmd: anytype) !void {
const params = (try cmd.params(
CdpStorage.CdpCookie,
)) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
try CdpStorage.setCdpCookie(&bc.session.cookie_jar, params);
try cmd.sendResult(.{ .success = true }, .{});
}
fn setCookies(cmd: anytype) !void {
const params = (try cmd.params(struct {
cookies: []const CdpStorage.CdpCookie,
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
for (params.cookies) |param| {
try CdpStorage.setCdpCookie(&bc.session.cookie_jar, param);
}
try cmd.sendResult(null, .{});
}
const GetCookiesParam = struct { urls: ?[]const []const u8 = null };
fn getCookies(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{};
// If not specified, use the URLs of the page and all of its subframes. TODO subframes
const page_url = if (bc.session.page) |*page| page.url.raw else null; // @speed: avoid repasing the URL
const param_urls = params.urls orelse &[_][]const u8{page_url orelse return error.InvalidParams};
var urls = try std.ArrayListUnmanaged(CdpStorage.PreparedUri).initCapacity(cmd.arena, param_urls.len);
for (param_urls) |url| {
const uri = std.Uri.parse(url) catch return error.InvalidParams;
urls.appendAssumeCapacity(.{
.host = try Cookie.parseDomain(cmd.arena, &uri, null),
.path = try Cookie.parsePath(cmd.arena, &uri, null),
.secure = std.mem.eql(u8, uri.scheme, "https"),
});
}
var jar = &bc.session.cookie_jar;
jar.removeExpired(null);
const writer = CdpStorage.CookieWriter{ .cookies = jar.cookies.items, .urls = urls.items };
try cmd.sendResult(.{ .cookies = writer }, .{});
}
// Upsert a header into the headers array.
// returns true if the header was added, false if it was updated
fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: std.http.Header) bool {
@@ -235,3 +352,77 @@ test "cdp.network setExtraHTTPHeaders" {
try ctx.processMessage(.{ .id = 5, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
try testing.expectEqual(bc.cdp.extra_headers.items.len, 0);
}
test "cdp.Network: cookies" {
const ResCookie = CdpStorage.ResCookie;
const CdpCookie = CdpStorage.CdpCookie;
var ctx = testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" });
// Initially empty
try ctx.processMessage(.{
.id = 3,
.method = "Network.getCookies",
.params = .{ .urls = &[_][]const u8{"https://example.com/pancakes"} },
});
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 3 });
// Has cookies after setting them
try ctx.processMessage(.{
.id = 4,
.method = "Network.setCookie",
.params = CdpCookie{ .name = "test3", .value = "valuenot3", .url = "https://car.example.com/defnotpancakes" },
});
try ctx.expectSentResult(null, .{ .id = 4 });
try ctx.processMessage(.{
.id = 5,
.method = "Network.setCookies",
.params = .{
.cookies = &[_]CdpCookie{
.{ .name = "test3", .value = "value3", .url = "https://car.example.com/pan/cakes" },
.{ .name = "test4", .value = "value4", .domain = "example.com", .path = "/mango" },
},
},
});
try ctx.expectSentResult(null, .{ .id = 5 });
try ctx.processMessage(.{
.id = 6,
.method = "Network.getCookies",
.params = .{ .urls = &[_][]const u8{"https://car.example.com/pan/cakes"} },
});
try ctx.expectSentResult(.{
.cookies = &[_]ResCookie{
.{ .name = "test3", .value = "value3", .domain = "car.example.com", .path = "/", .secure = true }, // No Pancakes!
},
}, .{ .id = 6 });
// deleteCookies
try ctx.processMessage(.{
.id = 7,
.method = "Network.deleteCookies",
.params = .{ .name = "test3", .domain = "car.example.com" },
});
try ctx.expectSentResult(null, .{ .id = 7 });
try ctx.processMessage(.{
.id = 8,
.method = "Storage.getCookies",
.params = .{ .browserContextId = "BID-S" },
});
// Just the untouched test4 should be in the result
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{.{ .name = "test4", .value = "value4", .domain = ".example.com", .path = "/mango" }} }, .{ .id = 8 });
// Empty after clearBrowserCookies
try ctx.processMessage(.{
.id = 9,
.method = "Network.clearBrowserCookies",
});
try ctx.expectSentResult(null, .{ .id = 9 });
try ctx.processMessage(.{
.id = 10,
.method = "Storage.getCookies",
.params = .{ .browserContextId = "BID-S" },
});
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 10 });
}

301
src/cdp/domains/storage.zig Normal file
View File

@@ -0,0 +1,301 @@
// 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 Allocator = std.mem.Allocator;
const log = @import("../../log.zig");
const Cookie = @import("../../browser/storage/storage.zig").Cookie;
const CookieJar = @import("../../browser/storage/storage.zig").CookieJar;
pub const PreparedUri = @import("../../browser/storage/cookie.zig").PreparedUri;
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
clearCookies,
setCookies,
getCookies,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.clearCookies => return clearCookies(cmd),
.getCookies => return getCookies(cmd),
.setCookies => return setCookies(cmd),
}
}
const BrowserContextParam = struct { browserContextId: ?[]const u8 = null };
fn clearCookies(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{};
if (params.browserContextId) |browser_context_id| {
if (std.mem.eql(u8, browser_context_id, bc.id) == false) {
return error.UnknownBrowserContextId;
}
}
bc.session.cookie_jar.clearRetainingCapacity();
return cmd.sendResult(null, .{});
}
fn getCookies(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{};
if (params.browserContextId) |browser_context_id| {
if (std.mem.eql(u8, browser_context_id, bc.id) == false) {
return error.UnknownBrowserContextId;
}
}
bc.session.cookie_jar.removeExpired(null);
const writer = CookieWriter{ .cookies = bc.session.cookie_jar.cookies.items };
try cmd.sendResult(.{ .cookies = writer }, .{});
}
fn setCookies(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const params = (try cmd.params(struct {
cookies: []const CdpCookie,
browserContextId: ?[]const u8 = null,
})) orelse return error.InvalidParams;
if (params.browserContextId) |browser_context_id| {
if (std.mem.eql(u8, browser_context_id, bc.id) == false) {
return error.UnknownBrowserContextId;
}
}
for (params.cookies) |param| {
try setCdpCookie(&bc.session.cookie_jar, param);
}
try cmd.sendResult(null, .{});
}
pub const SameSite = enum {
Strict,
Lax,
None,
};
pub const CookiePriority = enum {
Low,
Medium,
High,
};
pub const CookieSourceScheme = enum {
Unset,
NonSecure,
Secure,
};
pub const CookiePartitionKey = struct {
topLevelSite: []const u8,
hasCrossSiteAncestor: bool,
};
pub const CdpCookie = struct {
name: []const u8,
value: []const u8,
url: ?[]const u8 = null,
domain: ?[]const u8 = null,
path: ?[]const u8 = null,
secure: ?bool = null, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3
httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3
sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies
expires: ?f64 = null, // -1? says google
priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00
sameParty: ?bool = null,
sourceScheme: ?CookieSourceScheme = null,
// sourcePort: Temporary ability and it will be removed from CDP
partitionKey: ?CookiePartitionKey = null,
};
pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void {
if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) {
return error.NotYetImplementedParams;
}
var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator);
errdefer arena.deinit();
const a = arena.allocator();
// NOTE: The param.url can affect the default domain, (NOT path), secure, source port, and source scheme.
const uri = if (param.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null;
const uri_ptr = if (uri) |*u| u else null;
const domain = try Cookie.parseDomain(a, uri_ptr, param.domain);
const path = if (param.path == null) "/" else try Cookie.parsePath(a, null, param.path);
const secure = if (param.secure) |s| s else if (uri) |uri_| std.mem.eql(u8, uri_.scheme, "https") else false;
const cookie = Cookie{
.arena = arena,
.name = try a.dupe(u8, param.name),
.value = try a.dupe(u8, param.value),
.path = path,
.domain = domain,
.expires = param.expires,
.secure = secure,
.http_only = param.httpOnly,
.same_site = switch (param.sameSite) {
.Strict => .strict,
.Lax => .lax,
.None => .none,
},
};
try cookie_jar.add(cookie, std.time.timestamp());
}
pub const CookieWriter = struct {
cookies: []const Cookie,
urls: ?[]const PreparedUri = null,
pub fn jsonStringify(self: *const CookieWriter, w: anytype) !void {
self.writeCookies(w) catch |err| {
// The only error our jsonStringify method can return is @TypeOf(w).Error.
log.err(.cdp, "json stringify", .{ .err = err });
return error.OutOfMemory;
};
}
fn writeCookies(self: CookieWriter, w: anytype) !void {
try w.beginArray();
if (self.urls) |urls| {
for (self.cookies) |*cookie| {
for (urls) |*url| {
if (cookie.appliesTo(url, true, true, true)) { // TBD same_site, should we compare to the pages url?
try writeCookie(cookie, w);
break;
}
}
}
} else {
for (self.cookies) |*cookie| {
try writeCookie(cookie, w);
}
}
try w.endArray();
}
};
pub fn writeCookie(cookie: *const Cookie, w: anytype) !void {
try w.beginObject();
{
try w.objectField("name");
try w.write(cookie.name);
try w.objectField("value");
try w.write(cookie.value);
try w.objectField("domain");
try w.write(cookie.domain); // Should we hide a leading dot?
try w.objectField("path");
try w.write(cookie.path);
try w.objectField("expires");
try w.write(cookie.expires orelse -1);
// TODO size
try w.objectField("httpOnly");
try w.write(cookie.http_only);
try w.objectField("secure");
try w.write(cookie.secure);
try w.objectField("session");
try w.write(cookie.expires == null);
try w.objectField("sameSite");
switch (cookie.same_site) {
.none => try w.write("None"),
.lax => try w.write("Lax"),
.strict => try w.write("Strict"),
}
// TODO experimentals
}
try w.endObject();
}
const testing = @import("../testing.zig");
test "cdp.Storage: cookies" {
var ctx = testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" });
// Initially empty
try ctx.processMessage(.{
.id = 3,
.method = "Storage.getCookies",
.params = .{ .browserContextId = "BID-S" },
});
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 3 });
// Has cookies after setting them
try ctx.processMessage(.{
.id = 4,
.method = "Storage.setCookies",
.params = .{
.cookies = &[_]CdpCookie{
.{ .name = "test", .value = "value", .domain = "example.com", .path = "/mango" },
.{ .name = "test2", .value = "value2", .url = "https://car.example.com/pancakes" },
},
.browserContextId = "BID-S",
},
});
try ctx.expectSentResult(null, .{ .id = 4 });
try ctx.processMessage(.{
.id = 5,
.method = "Storage.getCookies",
.params = .{ .browserContextId = "BID-S" },
});
try ctx.expectSentResult(.{
.cookies = &[_]ResCookie{
.{ .name = "test", .value = "value", .domain = ".example.com", .path = "/mango" },
.{ .name = "test2", .value = "value2", .domain = "car.example.com", .path = "/", .secure = true }, // No Pancakes!
},
}, .{ .id = 5 });
// Empty after clearing cookies
try ctx.processMessage(.{
.id = 6,
.method = "Storage.clearCookies",
.params = .{ .browserContextId = "BID-S" },
});
try ctx.expectSentResult(null, .{ .id = 6 });
try ctx.processMessage(.{
.id = 7,
.method = "Storage.getCookies",
.params = .{ .browserContextId = "BID-S" },
});
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 7 });
}
pub const ResCookie = struct {
name: []const u8,
value: []const u8,
domain: []const u8,
path: []const u8 = "/",
expires: f64 = -1,
httpOnly: bool = false,
secure: bool = false,
sameSite: []const u8 = "None",
};

View File

@@ -66,11 +66,30 @@ fn getBrowserContexts(cmd: anytype) !void {
}
fn createBrowserContext(cmd: anytype) !void {
const params = try cmd.params(struct {
disposeOnDetach: bool = false,
proxyServer: ?[]const u8 = null,
proxyBypassList: ?[]const u8 = null,
originsWithUniversalNetworkAccess: ?[]const []const u8 = null,
});
if (params) |p| {
if (p.disposeOnDetach or p.proxyBypassList != null or p.originsWithUniversalNetworkAccess != null) std.debug.print("Target.createBrowserContext: Not implemented param set\n", .{});
}
const bc = cmd.createBrowserContext() catch |err| switch (err) {
error.AlreadyExists => return cmd.sendError(-32000, "Cannot have more than one browser context at a time"),
else => return err,
};
if (params) |p| {
if (p.proxyServer) |proxy| {
// For now the http client is not in the browser context so we assume there is just 1.
bc.http_proxy_before = cmd.cdp.browser.http_client.http_proxy;
const proxy_cp = try cmd.cdp.browser.http_client.allocator.dupe(u8, proxy);
cmd.cdp.browser.http_client.http_proxy = try std.Uri.parse(proxy_cp);
}
}
return cmd.sendResult(.{
.browserContextId = bc.id,
}, .{});

View File

@@ -1,10 +1,17 @@
const std = @import("std");
const builtin = @import("builtin");
pub fn lookup(value: []const u8) bool {
return public_suffix_list.has(value);
}
const public_suffix_list = std.StaticStringMap(void).initComptime([_]struct { []const u8, void }{
const public_suffix_list = std.StaticStringMap(void).initComptime(entries);
const entries: []const struct { []const u8, void } =
if (builtin.is_test) &.{
.{ "api.gov.uk", {} },
.{ "gov.uk", {} },
} else &.{
.{ "ac", {} },
.{ "com.ac", {} },
.{ "edu.ac", {} },
@@ -9739,4 +9746,4 @@ const public_suffix_list = std.StaticStringMap(void).initComptime([_]struct { []
.{ "basicserver.io", {} },
.{ "virtualserver.io", {} },
.{ "enterprisecloud.nu", {} },
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@ const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const server = @import("server.zig");
const App = @import("app.zig").App;
const http = @import("http/client.zig");
const Platform = @import("runtime/js.zig").Platform;
const Browser = @import("browser/browser.zig").Browser;
@@ -82,7 +83,10 @@ fn run(alloc: Allocator) !void {
var app = try App.init(alloc, .{
.run_mode = args.mode,
.platform = &platform,
.http_proxy = args.httpProxy(),
.proxy_type = args.proxyType(),
.proxy_auth = args.proxyAuth(),
.tls_verify_host = args.tlsVerifyHost(),
});
defer app.deinit();
@@ -126,7 +130,7 @@ fn run(alloc: Allocator) !void {
},
};
try page.wait();
try page.wait(std.time.ns_per_s * 3);
// dump
if (opts.dump) {
@@ -155,6 +159,20 @@ const Command = struct {
};
}
fn proxyType(self: *const Command) ?http.ProxyType {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.proxy_type,
else => unreachable,
};
}
fn proxyAuth(self: *const Command) ?http.ProxyAuth {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.proxy_auth,
else => unreachable,
};
}
fn logLevel(self: *const Command) ?log.Level {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_level,
@@ -198,6 +216,8 @@ const Command = struct {
const Common = struct {
http_proxy: ?std.Uri = null,
proxy_type: ?http.ProxyType = null,
proxy_auth: ?http.ProxyAuth = null,
tls_verify_host: bool = true,
log_level: ?log.Level = null,
log_format: ?log.Format = null,
@@ -216,6 +236,21 @@ const Command = struct {
\\--http_proxy The HTTP proxy to use for all HTTP requests.
\\ Defaults to none.
\\
\\--proxy_type The type of proxy: connect, forward.
\\ 'connect' creates a tunnel through the proxy via
\\ and initial CONNECT request.
\\ 'forward' sends the full URL in the request target
\\ and expects the proxy to MITM the request.
\\ Defaults to connect when --http_proxy is set.
\\
\\--proxy_bearer_token
\\ The token to send for bearer authentication with the proxy
\\ Proxy-Authorization: Bearer <token>
\\
\\--proxy_basic_auth
\\ The user:password to send for basic authentication with the proxy
\\ Proxy-Authorization: Basic <base64(user:password)>
\\
\\--log_level The log level: debug, info, warn, error or fatal.
\\ Defaults to
++ (if (builtin.mode == .Debug) " info." else "warn.") ++
@@ -456,6 +491,47 @@ fn parseCommonArg(
return error.InvalidArgument;
};
common.http_proxy = try std.Uri.parse(try allocator.dupe(u8, str));
if (common.http_proxy.?.host == null) {
log.fatal(.app, "invalid http proxy", .{ .arg = "--http_proxy", .hint = "missing scheme?" });
return error.InvalidArgument;
}
return true;
}
if (std.mem.eql(u8, "--proxy_type", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_type" });
return error.InvalidArgument;
};
common.proxy_type = std.meta.stringToEnum(http.ProxyType, str) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--proxy_type", .value = str });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--proxy_bearer_token", opt)) {
if (common.proxy_auth != null) {
log.fatal(.app, "proxy auth already set", .{ .arg = "--proxy_bearer_token" });
return error.InvalidArgument;
}
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" });
return error.InvalidArgument;
};
common.proxy_auth = .{ .bearer = .{ .token = str } };
return true;
}
if (std.mem.eql(u8, "--proxy_basic_auth", opt)) {
if (common.proxy_auth != null) {
log.fatal(.app, "proxy auth already set", .{ .arg = "--proxy_basic_auth" });
return error.InvalidArgument;
}
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_basic_auth" });
return error.InvalidArgument;
};
common.proxy_auth = .{ .basic = .{ .user_pass = str } };
return true;
}
@@ -505,7 +581,7 @@ fn parseCommonArg(
var it = std.mem.splitScalar(u8, str, ',');
while (it.next()) |part| {
try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--log_scope_filter", .value = part });
log.fatal(.app, "invalid option choice", .{ .arg = "--log_filter_scopes", .value = part });
return false;
});
}
@@ -527,7 +603,7 @@ test "tests:beforeAll" {
log.opts.format = .logfmt;
test_wg.startMany(3);
_ = try Platform.init();
const platform = try Platform.init();
{
const address = try std.net.Address.parseIp("127.0.0.1", 9582);
@@ -543,7 +619,7 @@ test "tests:beforeAll" {
{
const address = try std.net.Address.parseIp("127.0.0.1", 9583);
const thread = try std.Thread.spawn(.{}, serveCDP, .{address});
const thread = try std.Thread.spawn(.{}, serveCDP, .{ address, &platform });
thread.detach();
}
@@ -573,7 +649,8 @@ fn serveHTTP(address: std.net.Address) !void {
var conn = try listener.accept();
defer conn.stream.close();
var http_server = std.http.Server.init(conn, &read_buffer);
var connect_headers: std.ArrayListUnmanaged(std.http.Header) = .{};
REQUEST: while (true) {
var request = http_server.receiveHead() catch |err| switch (err) {
error.HttpConnectionClosing => continue :ACCEPT,
else => {
@@ -582,6 +659,21 @@ fn serveHTTP(address: std.net.Address) !void {
},
};
if (request.head.method == .CONNECT) {
try request.respond("", .{ .status = .ok });
// Proxy headers and destination headers are separated in the case of a CONNECT proxy
// We store the CONNECT headers, then continue with the request for the destination
var it = request.iterateHeaders();
while (it.next()) |hdr| {
try connect_headers.append(aa, .{
.name = try std.fmt.allocPrint(aa, "__{s}", .{hdr.name}),
.value = try aa.dupe(u8, hdr.value),
});
}
continue :REQUEST;
}
const path = request.head.target;
if (std.mem.eql(u8, path, "/loader")) {
try request.respond("Hello!", .{
@@ -619,6 +711,11 @@ fn serveHTTP(address: std.net.Address) !void {
.value = hdr.value,
});
}
if (connect_headers.items.len > 0) {
try headers.appendSlice(aa, connect_headers.items);
connect_headers.clearRetainingCapacity();
}
try headers.append(aa, .{ .name = "Connection", .value = "Close" });
try request.respond("over 9000!", .{
@@ -626,6 +723,8 @@ fn serveHTTP(address: std.net.Address) !void {
.extra_headers = headers.items,
});
}
continue :ACCEPT;
}
}
}
@@ -702,11 +801,12 @@ fn serveHTTPS(address: std.net.Address) !void {
}
}
fn serveCDP(address: std.net.Address) !void {
fn serveCDP(address: std.net.Address, platform: *const Platform) !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
var app = try App.init(gpa.allocator(), .{
.run_mode = .serve,
.tls_verify_host = false,
.platform = platform,
});
defer app.deinit();

View File

@@ -70,7 +70,13 @@ pub fn main() !void {
defer _ = test_arena.reset(.{ .retain_capacity = {} });
var err_out: ?[]const u8 = null;
const result = run(test_arena.allocator(), test_file, &loader, &err_out) catch |err| blk: {
const result = run(
test_arena.allocator(),
&platform,
test_file,
&loader,
&err_out,
) catch |err| blk: {
if (err_out == null) {
err_out = @errorName(err);
}
@@ -89,7 +95,13 @@ pub fn main() !void {
try writer.finalize();
}
fn run(arena: Allocator, test_file: []const u8, loader: *FileLoader, err_out: *?[]const u8) !?[]const u8 {
fn run(
arena: Allocator,
platform: *const Platform,
test_file: []const u8,
loader: *FileLoader,
err_out: *?[]const u8,
) !?[]const u8 {
// document
const html = blk: {
const full_path = try std.fs.path.join(arena, &.{ WPT_DIR, test_file });
@@ -110,6 +122,7 @@ fn run(arena: Allocator, test_file: []const u8, loader: *FileLoader, err_out: *?
var runner = try @import("testing.zig").jsRunner(arena, .{
.url = "http://127.0.0.1",
.html = html,
.platform = platform,
});
defer runner.deinit();
@@ -157,7 +170,7 @@ fn run(arena: Allocator, test_file: []const u8, loader: *FileLoader, err_out: *?
var try_catch: Env.TryCatch = undefined;
try_catch.init(runner.page.main_context);
defer try_catch.deinit();
try runner.page.loop.run();
try runner.page.loop.run(std.time.ns_per_ms * 200);
if (try_catch.hasCaught()) {
err_out.* = (try try_catch.err(arena)) orelse "unknwon error";

View File

@@ -29,6 +29,8 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const CALL_ARENA_RETAIN = 1024 * 16;
const CONTEXT_ARENA_RETAIN = 1024 * 64;
const js = @This();
// Global, should only be initialized once.
pub const Platform = struct {
inner: v8.Platform,
@@ -154,7 +156,12 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
return struct {
allocator: Allocator,
platform: ?*const Platform,
snapshot_creator: v8.SnapshotCreator,
// the global isolate
// owned by snapshot_creator.
isolate: v8.Isolate,
// just kept around because we need to free it on deinit
@@ -179,7 +186,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
const Opts = struct {};
pub fn init(allocator: Allocator, _: Opts) !*Self {
pub fn init(allocator: Allocator, platform: ?*const Platform, _: Opts) !*Self {
// var params = v8.initCreateParams();
var params = try allocator.create(v8.CreateParams);
errdefer allocator.destroy(params);
@@ -189,11 +196,22 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
params.array_buffer_allocator = v8.createDefaultArrayBufferAllocator();
errdefer v8.destroyArrayBufferAllocator(params.array_buffer_allocator.?);
var isolate = v8.Isolate.init(params);
errdefer isolate.deinit();
var snapshot_creator = v8.SnapshotCreator.init(params);
errdefer snapshot_creator.deinit();
isolate.enter();
errdefer isolate.exit();
var isolate = snapshot_creator.getIsolate();
// snapshot_creator enters the isolate for us.
isolate.setHostInitializeImportMetaObjectCallback(struct {
fn callback(c_context: ?*v8.C_Context, c_module: ?*v8.C_Module, c_meta: ?*v8.C_Value) callconv(.C) void {
const v8_context = v8.Context{ .handle = c_context.? };
const js_context: *JsContext = @ptrFromInt(v8_context.getEmbedderData(1).castTo(v8.BigInt).getUint64());
js_context.initializeImportMeta(v8.Module{ .handle = c_module.? }, v8.Object{ .handle = c_meta.? }) catch |err| {
log.err(.js, "import meta", .{ .err = err });
};
}
}.callback);
var temp_scope: v8.HandleScope = undefined;
v8.HandleScope.init(&temp_scope, isolate);
@@ -203,6 +221,8 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
errdefer allocator.destroy(env);
env.* = .{
.platform = platform,
.snapshot_creator = snapshot_creator,
.isolate = isolate,
.templates = undefined,
.allocator = allocator,
@@ -243,13 +263,66 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
}
pub fn deinit(self: *Self) void {
self.isolate.exit();
self.isolate.deinit();
// The snapshot_creator owns the isolate. So it exit and deinit it
// for us.
self.snapshot_creator.deinit();
v8.destroyArrayBufferAllocator(self.isolate_params.array_buffer_allocator.?);
self.allocator.destroy(self.isolate_params);
self.allocator.destroy(self);
}
pub fn snapshot(self: *Self, default_ctx: *const JsContext) v8.StartupData {
self.snapshot_creator.setDefaultContextWithCallbacks(
default_ctx.v8_context,
// SerializeInternalFieldsCallback serializes internal fields
// of V8 objects that contain embedder data
.{
.callback = struct {
fn callback(holder: ?*v8.C_Object, index: c_int, data: ?*anyopaque) callconv(.C) v8.StartupData {
_ = holder;
_ = index;
_ = data;
// TODO
std.debug.print("SerializeInternalFieldsCallback\n", .{});
return .{};
}
}.callback,
.data = null,
},
// SerializeContextDataCallback serializes context-specific
// state (globals, modules, etc.)
.{
.callback = struct {
fn callback(context: ?*v8.C_Context, index: c_int, data: ?*anyopaque) callconv(.C) v8.StartupData {
_ = context;
_ = index;
_ = data;
// TODO
std.debug.print("SerializeContextDataCallback\n", .{});
return .{};
}
}.callback,
.data = null,
},
// SerializeAPIWrapperCallback serializes API wrappers that
// bridge V8 and Native objects
.{
.callback = struct {
fn callback(holder: ?*v8.C_Object, ptr: ?*anyopaque, data: ?*anyopaque) callconv(.C) v8.StartupData {
_ = holder;
_ = ptr;
_ = data;
// TODO
std.debug.print("SerializeAPIWrapperCallback\n", .{});
return .{};
}
}.callback,
.data = null,
},
);
return self.snapshot_creator.createBlob();
}
pub fn newInspector(self: *Self, arena: Allocator, ctx: anytype) !Inspector {
return Inspector.init(arena, self.isolate, ctx);
}
@@ -258,6 +331,16 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
self.isolate.performMicrotasksCheckpoint();
}
pub fn pumpMessageLoop(self: *const Self) bool {
// assume it's not-null.
return self.platform.?.inner.pumpMessageLoop(self.isolate, false);
}
pub fn runIdleTasks(self: *const Self) void {
// assume it's not-null.
return self.platform.?.inner.runIdleTasks(self.isolate, 1);
}
pub fn newExecutionWorld(self: *Self) !ExecutionWorld {
return .{
.env = self,
@@ -337,6 +420,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
const env = self.env;
const isolate = env.isolate;
const Global = @TypeOf(global.*);
const templates = &self.env.templates;
var v8_context: v8.Context = blk: {
var temp_scope: v8.HandleScope = undefined;
@@ -351,7 +435,6 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
// All the FunctionTemplates that we created and setup in Env.init
// are now going to get associated with our global instance.
const templates = &self.env.templates;
inline for (Types, 0..) |s, i| {
const Struct = s.defaultValue().?;
const class_name = v8.String.initUtf8(isolate, comptime classNameForStruct(Struct));
@@ -400,7 +483,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
};
// For a Page we only create one HandleScope, it is stored in the main World (enter==true). A page can have multple contexts, 1 for each World.
// The main Context/Scope that enters and holds the HandleScope should therefore always be created first. Following other worlds for this page
// The main Context that enters and holds the HandleScope should therefore always be created first. Following other worlds for this page
// like isolated Worlds, will thereby place their objects on the main page's HandleScope. Note: In the furure the number of context will multiply multiple frames support
var handle_scope: ?v8.HandleScope = null;
if (enter) {
@@ -463,6 +546,38 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
}
}
// Primitive attributes are set directly on the FunctionTemplate
// when we setup the environment. But we cannot set more complex
// types (v8 will crash).
//
// Plus, just to create more complex types, we always need a
// context, i.e. an Array has to have a Context to exist.
//
// As far as I can tell, getting the FunctionTemplate's object
// and setting values directly on it, for each context, is the
// way to do this.
inline for (Types, 0..) |s, i| {
const Struct = s.defaultValue().?;
inline for (@typeInfo(Struct).@"struct".decls) |declaration| {
const name = declaration.name;
if (comptime name[0] == '_') {
const value = @field(Struct, name);
if (comptime isComplexAttributeType(@typeInfo(@TypeOf(value)))) {
const js_obj = templates[i].getFunction(v8_context).toObject();
const js_name = v8.String.initUtf8(isolate, name[1..]).toName();
const js_val = try js_context.zigValueToJs(value);
if (!js_obj.setValue(v8_context, js_name, js_val)) {
log.fatal(.app, "set class attribute", .{
.@"struct" = @typeName(Struct),
.name = name,
});
}
}
}
}
}
_ = try js_context._mapZigInstanceToJs(v8_context.getGlobal(), global);
return js_context;
}
@@ -483,6 +598,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
};
const PersistentObject = v8.Persistent(v8.Object);
const PersistentModule = v8.Persistent(v8.Module);
const PersistentFunction = v8.Persistent(v8.Function);
// Loosely maps to a Browser Page.
@@ -540,6 +656,16 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
// Some Zig types have code to execute when the call scope ends
call_scope_end_callbacks: std.ArrayListUnmanaged(CallScopeEndCallback) = .empty,
// Our module cache: normalized module specifier => module.
module_cache: std.StringHashMapUnmanaged(PersistentModule) = .empty,
// Module => Path. The key is the module hashcode (module.getIdentityHash)
// and the value is the full path to the module. We need to capture this
// so that when we're asked to resolve a dependent module, and all we're
// given is the specifier, we can form the full path. The full path is
// necessary to lookup/store the dependent module in the module_cache.
module_identifier: std.AutoHashMapUnmanaged(u32, []const u8) = .empty,
const ModuleLoader = struct {
ptr: *anyopaque,
func: *const fn (ptr: *anyopaque, specifier: []const u8) anyerror!?[]const u8,
@@ -573,6 +699,13 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
}
}
{
var it = self.module_cache.valueIterator();
while (it.next()) |p| {
p.deinit();
}
}
for (self.callbacks.items) |*cb| {
cb.deinit();
}
@@ -614,6 +747,10 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
}
// Executes the src
pub fn eval(self: *JsContext, src: []const u8, name: ?[]const u8) !void {
_ = try self.exec(src, name);
}
pub fn exec(self: *JsContext, src: []const u8, name: ?[]const u8) !Value {
const isolate = self.isolate;
const v8_context = self.v8_context;
@@ -637,25 +774,49 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
// compile and eval a JS module
// It doesn't wait for callbacks execution
pub fn module(self: *JsContext, src: []const u8, name: []const u8) !union(enum) { value: Value, exception: Exception } {
const v8_context = self.v8_context;
const m = try compileModule(self.isolate, src, name);
pub fn module(self: *JsContext, src: []const u8, url: []const u8, cacheable: bool) !void {
if (!cacheable) {
return self.moduleNoCache(src, url);
}
const arena = self.context_arena;
const gop = try self.module_cache.getOrPut(arena, url);
if (gop.found_existing) {
return;
}
errdefer _ = self.module_cache.remove(url);
const m = try compileModule(self.isolate, src, url);
const owned_url = try arena.dupe(u8, url);
try self.module_identifier.putNoClobber(arena, m.getIdentityHash(), owned_url);
errdefer _ = self.module_identifier.remove(m.getIdentityHash());
gop.key_ptr.* = owned_url;
gop.value_ptr.* = PersistentModule.init(self.isolate, m);
// instantiate
// resolveModuleCallback loads module's dependencies.
const ok = m.instantiate(v8_context, resolveModuleCallback) catch {
return error.ExecutionError;
};
if (!ok) {
const v8_context = self.v8_context;
if (try m.instantiate(v8_context, resolveModuleCallback) == false) {
return error.ModuleInstantiationError;
}
// evaluate
const value = m.evaluate(v8_context) catch {
return .{ .exception = self.createException(m.getException()) };
};
return .{ .value = self.createValue(value) };
_ = try m.evaluate(v8_context);
}
fn moduleNoCache(self: *JsContext, src: []const u8, url: []const u8) !void {
const m = try compileModule(self.isolate, src, url);
const arena = self.context_arena;
const owned_url = try arena.dupe(u8, url);
try self.module_identifier.putNoClobber(arena, m.getIdentityHash(), owned_url);
const v8_context = self.v8_context;
if (try m.instantiate(v8_context, resolveModuleCallback) == false) {
return error.ModuleInstantiationError;
}
_ = try m.evaluate(v8_context);
}
// Wrap a v8.Exception
@@ -1158,7 +1319,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
}
if (!js_value.isArray()) {
return .{.invalid = {}};
return .{ .invalid = {} };
}
// This can get tricky.
@@ -1196,45 +1357,99 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
return .{ .invalid = {} };
}
pub fn throw(self: *JsContext, err: []const u8) Exception {
const js_value = js.createException(self.isolate, err);
return self.createException(js_value);
}
fn initializeImportMeta(self: *JsContext, m: v8.Module, meta: v8.Object) !void {
const url = self.module_identifier.get(m.getIdentityHash()) orelse {
// Shouldn't be possible.
return error.UnknownModuleReferrer;
};
const js_key = v8.String.initUtf8(self.isolate, "url");
const js_value = try self.zigValueToJs(url);
const res = meta.defineOwnProperty(self.v8_context, js_key.toName(), js_value, 0) orelse false;
if (!res) {
return error.FailedToSet;
}
}
// Callback from V8, asking us to load a module. The "specifier" is
// the src of the module to load.
fn resolveModuleCallback(
c_context: ?*const v8.C_Context,
c_specifier: ?*const v8.C_String,
import_attributes: ?*const v8.C_FixedArray,
referrer: ?*const v8.C_Module,
c_referrer: ?*const v8.C_Module,
) callconv(.C) ?*const v8.C_Module {
_ = import_attributes;
_ = referrer;
std.debug.assert(c_context != null);
const v8_context = v8.Context{ .handle = c_context.? };
const self: *JsContext = @ptrFromInt(v8_context.getEmbedderData(1).castTo(v8.BigInt).getUint64());
// build the specifier value.
const specifier = valueToString(
const specifier = jsStringToZig(self.call_arena, .{ .handle = c_specifier.? }, self.isolate) catch |err| {
log.err(.js, "resolve module", .{ .err = err });
return null;
};
const referrer = v8.Module{ .handle = c_referrer.? };
return self._resolveModuleCallback(referrer, specifier) catch |err| {
log.err(.js, "resolve module", .{
.err = err,
.specifier = specifier,
});
return null;
};
}
fn _resolveModuleCallback(
self: *JsContext,
referrer: v8.Module,
specifier: []const u8,
) !?*const v8.C_Module {
const referrer_path = self.module_identifier.get(referrer.getIdentityHash()) orelse {
// Shouldn't be possible.
return error.UnknownModuleReferrer;
};
const normalized_specifier = try @import("../url.zig").stitch(
self.call_arena,
.{ .handle = c_specifier.? },
self.isolate,
v8_context,
) catch |e| {
log.err(.js, "resolve module specifier", .{ .err = e });
return null;
};
specifier,
referrer_path,
.{ .alloc = .if_needed },
);
if (self.module_cache.get(normalized_specifier)) |pm| {
return pm.handle;
}
// not currently needed
// const referrer_module = if (referrer) |ref| v8.Module{ .handle = ref } else null;
const module_loader = self.module_loader;
const source = module_loader.func(module_loader.ptr, specifier) catch |err| {
log.err(.js, "resolve module fetch error", .{ .specifier = specifier, .err = err });
return null;
} orelse return null;
const source = try module_loader.func(module_loader.ptr, normalized_specifier) orelse return null;
const m = compileModule(self.isolate, source, specifier) catch |err| {
log.err(.js, "resolve module compile error", .{ .specifier = specifier, .err = err });
var try_catch: TryCatch = undefined;
try_catch.init(self);
defer try_catch.deinit();
const m = compileModule(self.isolate, source, normalized_specifier) catch |err| {
log.warn(.js, "compile resolved module", .{
.specifier = specifier,
.stack = try_catch.stack(self.call_arena) catch null,
.src = try_catch.sourceLine(self.call_arena) catch "err",
.line = try_catch.sourceLineNumber() orelse 0,
.exception = (try_catch.exception(self.call_arena) catch @errorName(err)) orelse @errorName(err),
});
return null;
};
// We were hoping to find the module in our cache, and thus used
// the short-lived call_arena to create the normalized_specifier.
// But now this'll live for the lifetime of the context.
const arena = self.context_arena;
const owned_specifier = try arena.dupe(u8, normalized_specifier);
try self.module_cache.put(arena, owned_specifier, PersistentModule.init(self.isolate, m));
try self.module_identifier.putNoClobber(arena, m.getIdentityHash(), owned_specifier);
return m.handle;
}
};
@@ -1393,7 +1608,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
};
pub fn setIndex(self: JsObject, index: u32, value: anytype, opts: SetOpts) !void {
const key = switch (index) {
inline 0...1000 => |i| std.fmt.comptimePrint("{d}", .{i}),
inline 0...50 => |i| std.fmt.comptimePrint("{d}", .{i}),
else => try std.fmt.allocPrint(self.js_context.context_arena, "{d}", .{index}),
};
return self.set(key, value, opts);
@@ -1492,6 +1707,11 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
const str = try self.js_obj.getConstructorName();
return jsStringToZig(allocator, str, self.js_context.isolate);
}
pub fn toZig(self: JsObject, comptime Struct: type, comptime name: []const u8, comptime T: type) !T {
const named_function = comptime NamedFunction.init(Struct, name);
return self.js_context.jsValueToZig(named_function, T, self.js_obj.toValue());
}
};
// This only exists so that we know whether a function wants the opaque
@@ -1547,6 +1767,20 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
return try valueToString(allocator, s, js_context.isolate, js_context.v8_context);
}
// the caller needs to deinit the string returned
pub fn sourceLine(self: TryCatch, allocator: Allocator) !?[]const u8 {
const js_context = self.js_context;
const msg = self.inner.getMessage() orelse return null;
const sl = msg.getSourceLine(js_context.v8_context) orelse return null;
return try jsStringToZig(allocator, sl, js_context.isolate);
}
pub fn sourceLineNumber(self: TryCatch) ?u32 {
const js_context = self.js_context;
const msg = self.inner.getMessage() orelse return null;
return msg.getLineNumber(js_context.v8_context);
}
// a shorthand method to return either the entire stack message
// or just the exception message
// - in Debug mode return the stack if available
@@ -1691,6 +1925,8 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
inner: v8.Value,
js_context: *const JsContext,
const _EXCEPTION_ID_KLUDGE = true;
// the caller needs to deinit the string returned
pub fn exception(self: Exception, allocator: Allocator) ![]const u8 {
const js_context = self.js_context;
@@ -1728,6 +1964,13 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
}
};
pub fn UndefinedOr(comptime T: type) type {
return union(enum) {
undefined: void,
value: T,
};
}
fn compileModule(isolate: v8.Isolate, src: []const u8, name: []const u8) !v8.Module {
// compile
const script_name = v8.String.initUtf8(isolate, name);
@@ -1782,12 +2025,14 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
if (comptime name[0] == '_') {
switch (@typeInfo(@TypeOf(@field(Struct, name)))) {
.@"fn" => generateMethod(Struct, name, isolate, template_proto),
else => generateAttribute(Struct, name, isolate, template, template_proto),
else => |ti| if (!comptime isComplexAttributeType(ti)) {
generateAttribute(Struct, name, isolate, template, template_proto);
},
}
} else if (comptime std.mem.startsWith(u8, name, "get_")) {
generateProperty(Struct, name[4..], isolate, template_proto);
} else if (comptime std.mem.startsWith(u8, name, "static_")) {
generateFunction(Struct, name[7..], isolate, template_proto);
generateFunction(Struct, name[7..], isolate, template);
}
}
@@ -1806,7 +2051,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
}
generateIndexer(Struct, template_proto);
generateNamedIndexer(Struct, template_proto);
generateNamedIndexer(Struct, template.getInstanceTemplate());
generateUndetectable(Struct, template.getInstanceTemplate());
}
@@ -1877,7 +2122,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
template_proto.set(js_name, function_template, v8.PropertyAttribute.None);
}
fn generateFunction(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template_proto: v8.ObjectTemplate) void {
fn generateFunction(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template: v8.FunctionTemplate) void {
const js_name = v8.String.initUtf8(isolate, name).toName();
const function_template = v8.FunctionTemplate.initCallback(isolate, struct {
fn callback(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
@@ -1891,7 +2136,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
};
}
}.callback);
template_proto.set(js_name, function_template, v8.PropertyAttribute.None);
template.set(js_name, function_template, v8.PropertyAttribute.None);
}
fn generateAttribute(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template: v8.FunctionTemplate, template_proto: v8.ObjectTemplate) void {
@@ -1903,7 +2148,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
// apply it both to the type itself
template.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete);
// andto instances of the type
// and to instances of the type
template_proto.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete);
}
@@ -1991,7 +2236,8 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
}
return;
}
const configuration = v8.NamedPropertyHandlerConfiguration{
var configuration = v8.NamedPropertyHandlerConfiguration{
.getter = struct {
fn callback(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 {
const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
@@ -2013,13 +2259,37 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
.flags = v8.PropertyHandlerFlags.OnlyInterceptStrings | v8.PropertyHandlerFlags.NonMasking,
};
// If you're trying to implement setter, read:
// https://groups.google.com/g/v8-users/c/8tahYBsHpgY/m/IteS7Wn2AAAJ
// The issue I had was
// (a) where to attache it: does it go ont he instance_template
// instead of the prototype?
// (b) defining the getter or query to respond with the
// PropertyAttribute to indicate if the property can be set
if (@hasDecl(Struct, "named_set")) {
configuration.setter = struct {
fn callback(c_name: ?*const v8.C_Name, c_value: ?*const v8.C_Value, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 {
const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
var caller = Caller(Self, State).init(info);
defer caller.deinit();
const named_function = comptime NamedFunction.init(Struct, "named_set");
return caller.setNamedIndex(Struct, named_function, .{ .handle = c_name.? }, .{ .handle = c_value.? }, info) catch |err| blk: {
caller.handleError(Struct, named_function, err, info);
break :blk v8.Intercepted.No;
};
}
}.callback;
}
if (@hasDecl(Struct, "named_delete")) {
configuration.deleter = struct {
fn callback(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 {
const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
var caller = Caller(Self, State).init(info);
defer caller.deinit();
const named_function = comptime NamedFunction.init(Struct, "named_delete");
return caller.deleteNamedIndex(Struct, named_function, .{ .handle = c_name.? }, info) catch |err| blk: {
caller.handleError(Struct, named_function, err, info);
break :blk v8.Intercepted.No;
};
}
}.callback;
}
template_proto.setNamedProperty(configuration, null);
}
@@ -2090,7 +2360,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
const T = @TypeOf(value);
switch (@typeInfo(T)) {
.void, .bool, .int, .comptime_int, .float, .comptime_float => {
.void, .bool, .int, .comptime_int, .float, .comptime_float, .@"enum" => {
// Need to do this to keep the compiler happy
// simpleZigValueToJs handles all of these cases.
unreachable;
@@ -2161,6 +2431,10 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
return value.js_obj.toValue();
}
if (@hasDecl(T, "_EXCEPTION_ID_KLUDGE")) {
return isolate.throwException(value.inner);
}
if (s.is_tuple) {
// return the tuple struct as an array
var js_arr = v8.Array.init(isolate, @intCast(s.fields.len));
@@ -2216,10 +2490,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
fn typeTaggedAnyOpaque(comptime named_function: NamedFunction, comptime R: type, js_obj: v8.Object) !R {
const ti = @typeInfo(R);
if (ti != .pointer) {
@compileError(std.fmt.comptimePrint(
"{s} has a non-pointer Zig parameter type: {s}",
.{ named_function.full_name, @typeName(R) },
));
@compileError(named_function.full_name ++ "has a non-pointer Zig parameter type: " ++ @typeName(R));
}
const T = ti.pointer.child;
@@ -2239,10 +2510,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
const type_name = @typeName(T);
if (@hasField(TypeLookup, type_name) == false) {
@compileError(std.fmt.comptimePrint(
"{s} has an unknown Zig type: {s}",
.{ named_function.full_name, @typeName(R) },
));
@compileError(named_function.full_name ++ "has an unknown Zig type: " ++ @typeName(R));
}
const op = js_obj.getInternalField(0).castTo(v8.External).get();
@@ -2369,6 +2637,20 @@ fn isEmpty(comptime T: type) bool {
return @typeInfo(T) != .@"opaque" and @sizeOf(T) == 0 and @hasDecl(T, "js_legacy_factory") == false;
}
// Attributes that return a primitive type are setup directly on the
// FunctionTemplate when the Env is setup. More complex types need a v8.Context
// and cannot be set directly on the FunctionTemplate.
// We default to saying types are primitives because that's mostly what
// we have. If we add a new complex type that isn't explictly handled here,
// we'll get a compiler error in simpleZigValueToJs, and can then explicitly
// add the type here.
fn isComplexAttributeType(ti: std.builtin.Type) bool {
return switch (ti) {
.array => true,
else => false,
};
}
// Responsible for calling Zig functions from JS invokations. This could
// probably just contained in ExecutionWorld, but having this specific logic, which
// is somewhat repetitive between constructors, functions, getters, etc contained
@@ -2448,10 +2730,12 @@ fn Caller(comptime E: type, comptime State: type) type {
}
fn method(self: *Self, comptime Struct: type, comptime named_function: NamedFunction, info: v8.FunctionCallbackInfo) !void {
if (comptime isSelfReceiver(Struct, named_function) == false) {
return self.function(Struct, named_function, info);
}
const js_context = self.js_context;
const func = @field(Struct, named_function.name);
comptime assertSelfReceiver(Struct, named_function);
var args = try self.getArgs(Struct, named_function, 1, info);
const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(Struct), info.getThis());
@@ -2507,37 +2791,63 @@ fn Caller(comptime E: type, comptime State: type) type {
}
fn getNamedIndex(self: *Self, comptime Struct: type, comptime named_function: NamedFunction, name: v8.Name, info: v8.PropertyCallbackInfo) !u8 {
const js_context = self.js_context;
const func = @field(Struct, named_function.name);
const NamedGet = @TypeOf(func);
if (@typeInfo(NamedGet).@"fn".return_type == null) {
@compileError(named_function.full_name ++ " must have a return type");
}
comptime assertSelfReceiver(Struct, named_function);
var has_value = true;
var args: ParamterTypes(NamedGet) = undefined;
const arg_fields = @typeInfo(@TypeOf(args)).@"struct".fields;
switch (arg_fields.len) {
0, 1, 2 => @compileError(named_function.full_name ++ " must take at least a u32 and *bool parameter"),
3, 4 => {
var args = try self.getArgs(Struct, named_function, 3, info);
const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(Struct), info.getThis());
comptime assertSelfReceiver(Struct, named_function);
@field(args, "0") = zig_instance;
@field(args, "1") = try self.nameToString(name);
@field(args, "2") = &has_value;
if (comptime arg_fields.len == 4) {
comptime assertIsStateArg(Struct, named_function, 3);
@field(args, "3") = js_context.state;
}
},
else => @compileError(named_function.full_name ++ " has too many parmaters"),
}
const res = @call(.auto, func, args);
if (has_value == false) {
return v8.Intercepted.No;
}
info.getReturnValue().set(try js_context.zigValueToJs(res));
info.getReturnValue().set(try self.js_context.zigValueToJs(res));
return v8.Intercepted.Yes;
}
fn setNamedIndex(self: *Self, comptime Struct: type, comptime named_function: NamedFunction, name: v8.Name, js_value: v8.Value, info: v8.PropertyCallbackInfo) !u8 {
const js_context = self.js_context;
const func = @field(Struct, named_function.name);
comptime assertSelfReceiver(Struct, named_function);
var has_value = true;
var args = try self.getArgs(Struct, named_function, 4, info);
const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(Struct), info.getThis());
@field(args, "0") = zig_instance;
@field(args, "1") = try self.nameToString(name);
@field(args, "2") = try js_context.jsValueToZig(named_function, @TypeOf(@field(args, "2")), js_value);
@field(args, "3") = &has_value;
const res = @call(.auto, func, args);
return namedSetOrDeleteCall(res, has_value);
}
fn deleteNamedIndex(self: *Self, comptime Struct: type, comptime named_function: NamedFunction, name: v8.Name, info: v8.PropertyCallbackInfo) !u8 {
const func = @field(Struct, named_function.name);
comptime assertSelfReceiver(Struct, named_function);
var has_value = true;
var args = try self.getArgs(Struct, named_function, 3, info);
const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(Struct), info.getThis());
@field(args, "0") = zig_instance;
@field(args, "1") = try self.nameToString(name);
@field(args, "2") = &has_value;
const res = @call(.auto, func, args);
return namedSetOrDeleteCall(res, has_value);
}
fn namedSetOrDeleteCall(res: anytype, has_value: bool) !u8 {
if (@typeInfo(@TypeOf(res)) == .error_union) {
_ = try res;
}
if (has_value == false) {
return v8.Intercepted.No;
}
return v8.Intercepted.Yes;
}
@@ -2545,18 +2855,36 @@ fn Caller(comptime E: type, comptime State: type) type {
return valueToString(self.call_arena, .{ .handle = name.handle }, self.isolate, self.v8_context);
}
fn isSelfReceiver(comptime Struct: type, comptime named_function: NamedFunction) bool {
return checkSelfReceiver(Struct, named_function, false);
}
fn assertSelfReceiver(comptime Struct: type, comptime named_function: NamedFunction) void {
_ = checkSelfReceiver(Struct, named_function, true);
}
fn checkSelfReceiver(comptime Struct: type, comptime named_function: NamedFunction, comptime fail: bool) bool {
const func = @field(Struct, named_function.name);
const params = @typeInfo(@TypeOf(func)).@"fn".params;
if (params.len == 0) {
if (fail) {
@compileError(named_function.full_name ++ " must have a self parameter");
}
const R = Receiver(Struct);
return false;
}
const R = Receiver(Struct);
const first_param = params[0].type.?;
if (first_param != *R and first_param != *const R) {
@compileError(std.fmt.comptimePrint("The first parameter to {s} must be a *{s} or *const {s}. Got: {s}", .{ named_function.full_name, @typeName(R), @typeName(R), @typeName(first_param) }));
if (fail) {
@compileError(std.fmt.comptimePrint("The first parameter to {s} must be a *{s} or *const {s}. Got: {s}", .{
named_function.full_name,
@typeName(R),
@typeName(R),
@typeName(first_param),
}));
}
return false;
}
return true;
}
fn assertIsStateArg(comptime Struct: type, comptime named_function: NamedFunction, index: comptime_int) void {
@@ -2694,7 +3022,7 @@ fn Caller(comptime E: type, comptime State: type) type {
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime isState(params[params.len - 1].type.?)) {
@field(args, std.fmt.comptimePrint("{d}", .{params.len - 1 + offset})) = self.js_context.state;
@field(args, tupleFieldName(params.len - 1 + offset)) = self.js_context.state;
break :blk params[0 .. params.len - 1];
}
@@ -2702,14 +3030,14 @@ fn Caller(comptime E: type, comptime State: type) type {
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime isJsThis(params[params.len - 1].type.?)) {
@field(args, std.fmt.comptimePrint("{d}", .{params.len - 1 + offset})) = .{ .obj = .{
@field(args, tupleFieldName(params.len - 1 + offset)) = .{ .obj = .{
.js_context = js_context,
.js_obj = info.getThis(),
} };
// AND the 2nd last parameter is state
if (params.len > 1 and comptime isState(params[params.len - 2].type.?)) {
@field(args, std.fmt.comptimePrint("{d}", .{params.len - 2 + offset})) = self.js_context.state;
@field(args, tupleFieldName(params.len - 2 + offset)) = self.js_context.state;
break :blk params[0 .. params.len - 2];
}
@@ -2959,6 +3287,12 @@ fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bool)
}
},
.@"union" => return simpleZigValueToJs(isolate, std.meta.activeTag(value), fail),
.@"enum" => {
const T = @TypeOf(value);
if (@hasDecl(T, "toString")) {
return simpleZigValueToJs(isolate, value.toString(), fail);
}
},
else => {},
}
if (fail) {
@@ -3030,7 +3364,19 @@ fn ParamterTypes(comptime F: type) type {
}
fn tupleFieldName(comptime i: usize) [:0]const u8 {
return std.fmt.comptimePrint("{d}", .{i});
return switch (i) {
0 => "0",
1 => "1",
2 => "2",
3 => "3",
4 => "4",
5 => "5",
6 => "6",
7 => "7",
8 => "8",
9 => "9",
else => std.fmt.comptimePrint("{d}", .{i}),
};
}
fn createException(isolate: v8.Isolate, msg: []const u8) v8.Value {
@@ -3123,8 +3469,8 @@ const TaggedAnyOpaque = struct {
fn valueToDetailString(arena: Allocator, value: v8.Value, isolate: v8.Isolate, v8_context: v8.Context) ![]u8 {
var str: ?v8.String = null;
if (value.isObject() and !value.isFunction()) {
str = try v8.Json.stringify(v8_context, value, null);
if (value.isObject() and !value.isFunction()) blk: {
str = v8.Json.stringify(v8_context, value, null) catch break :blk;
if (str.?.lenUtf8(isolate) == 2) {
// {} isn't useful, null this so that we can get the toDetailString
@@ -3137,10 +3483,24 @@ fn valueToDetailString(arena: Allocator, value: v8.Value, isolate: v8.Isolate, v
str = try value.toDetailString(v8_context);
}
return jsStringToZig(arena, str.?, isolate);
const s = try jsStringToZig(arena, str.?, isolate);
if (comptime builtin.mode == .Debug) {
if (std.mem.eql(u8, s, "[object Object]")) {
if (debugValueToString(arena, value.castTo(v8.Object), isolate, v8_context)) |ds| {
return ds;
} else |err| {
log.err(.js, "debug serialize value", .{.err = err});
}
}
}
return s;
}
fn valueToString(allocator: Allocator, value: v8.Value, isolate: v8.Isolate, v8_context: v8.Context) ![]u8 {
if (value.isSymbol()) {
// symbol's can't be converted to a string
return allocator.dupe(u8, "$Symbol");
}
const str = try value.toString(v8_context);
return jsStringToZig(allocator, str, isolate);
}
@@ -3162,6 +3522,38 @@ fn jsStringToZig(allocator: Allocator, str: v8.String, isolate: v8.Isolate) ![]u
return buf;
}
fn debugValueToString(arena: Allocator, js_obj: v8.Object, isolate: v8.Isolate, v8_context: v8.Context) ![]u8 {
if (comptime builtin.mode != .Debug) {
@compileError("debugValue can only be called in debug mode");
}
var arr: std.ArrayListUnmanaged(u8) = .empty;
var writer = arr.writer(arena);
const names_arr = js_obj.getOwnPropertyNames(v8_context);
const names_obj = names_arr.castTo(v8.Object);
const len = names_arr.length();
try writer.writeAll("(JSON.stringify failed, dumping top-level fields)\n");
for (0..len) |i| {
const field_name = try names_obj.getAtIndex(v8_context, @intCast(i));
const field_value = try js_obj.getValue(v8_context, field_name);
const name = try valueToString(arena, field_name, isolate, v8_context);
const value = try valueToString(arena, field_value, isolate, v8_context);
try writer.writeAll(name);
try writer.writeAll(": ");
if (std.mem.indexOfAny(u8, value, &std.ascii.whitespace) == null) {
try writer.writeAll(value);
} else {
try writer.writeByte('"');
try writer.writeAll(value);
try writer.writeByte('"');
}
try writer.writeByte(' ');
}
return arr.items;
}
fn stackForLogs(arena: Allocator, isolate: v8.Isolate) !?[]const u8 {
std.debug.assert(builtin.mode == .Debug);

View File

@@ -23,6 +23,8 @@ const MemoryPool = std.heap.MemoryPool;
const log = @import("../log.zig");
pub const IO = @import("tigerbeetle-io").IO;
const RUN_DURATION = 10 * std.time.ns_per_ms;
// SingleThreaded I/O Loop based on Tigerbeetle io_uring loop.
// On Linux it's using io_uring.
// On MacOS and Windows it's using kqueue/IOCP with a ring design.
@@ -82,7 +84,7 @@ pub const Loop = struct {
// run tail events. We do run the tail events to ensure all the
// contexts are correcly free.
while (self.pending_network_count != 0 or self.pending_timeout_count != 0) {
self.io.run_for_ns(std.time.ns_per_ms * 10) catch |err| {
self.io.run_for_ns(RUN_DURATION) catch |err| {
log.err(.loop, "deinit", .{ .err = err });
break;
};
@@ -102,12 +104,16 @@ pub const Loop = struct {
// Stops when there is no more I/O events registered on the loop.
// Note that I/O events callbacks might register more I/O events
// on the go when they are executed (ie. nested I/O events).
pub fn run(self: *Self) !void {
pub fn run(self: *Self, wait_ns: usize) !void {
// stop repeating / interval timeouts from re-registering
self.stopping = true;
defer self.stopping = false;
while (self.pending_network_count != 0 or self.pending_timeout_count != 0) {
const max_iterations = wait_ns / (RUN_DURATION);
for (0..max_iterations) |_| {
if (self.pending_network_count == 0 and self.pending_timeout_count == 0) {
break;
}
self.io.run_for_ns(std.time.ns_per_ms * 10) catch |err| {
log.err(.loop, "deinit", .{ .err = err });
break;
@@ -187,6 +193,11 @@ pub const Loop = struct {
}
pub fn timeout(self: *Self, nanoseconds: u63, callback_node: ?*CallbackNode) !usize {
if (self.stopping and nanoseconds > std.time.ns_per_ms * 500) {
// we're trying to shutdown, we probably don't want to wait for a new
// long timeout
return 0;
}
const completion = try self.alloc.create(Completion);
errdefer self.alloc.destroy(completion);
completion.* = undefined;

View File

@@ -42,7 +42,7 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty
const self = try allocator.create(Self);
errdefer allocator.destroy(self);
self.env = try Env.init(allocator, .{});
self.env = try Env.init(allocator, null, .{});
errdefer self.env.deinit();
self.executor = try self.env.newExecutionWorld();

View File

@@ -19,6 +19,8 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const Platform = @import("runtime/js.zig").Platform;
pub const allocator = std.testing.allocator;
pub const expectError = std.testing.expectError;
pub const expect = std.testing.expect;
@@ -66,7 +68,10 @@ pub fn expectEqual(expected: anytype, actual: anytype) !void {
if (@typeInfo(@TypeOf(expected)) == .null) {
return std.testing.expectEqual(null, actual);
}
return expectEqual(expected, actual.?);
if (actual) |_actual| {
return expectEqual(expected, _actual);
}
return std.testing.expectEqual(expected, null);
},
.@"union" => |union_info| {
if (union_info.tag_type == null) {
@@ -380,6 +385,7 @@ pub const JsRunner = struct {
var app = try App.init(alloc, .{
.run_mode = .serve,
.tls_verify_host = false,
.platform = opts.platform,
});
errdefer app.deinit();
@@ -435,7 +441,7 @@ pub const JsRunner = struct {
}
return err;
};
try self.page.loop.run();
try self.page.loop.run(std.time.ns_per_ms * 200);
@import("root").js_runner_duration += std.time.Instant.since(try std.time.Instant.now(), start);
if (case.@"1") |expected| {
@@ -471,6 +477,7 @@ pub const JsRunner = struct {
};
const RunnerOpts = struct {
platform: ?*const Platform = null,
url: []const u8 = "https://lightpanda.io/opensource-browser/",
html: []const u8 =
\\ <div id="content">

View File

@@ -110,7 +110,13 @@ pub const URL = struct {
}
return src;
}
if (src.len == 0) {
var normalized_src = src;
while (std.mem.startsWith(u8, normalized_src, "./")) {
normalized_src = normalized_src[2..];
}
if (normalized_src.len == 0) {
if (opts.alloc == .always) {
return allocator.dupe(u8, base);
}
@@ -125,7 +131,12 @@ pub const URL = struct {
}
};
const normalized_src = if (src[0] == '/') src[1..] else src;
if (normalized_src[0] == '/') {
if (std.mem.indexOfScalarPos(u8, base, protocol_end, '/')) |pos| {
return std.fmt.allocPrint(allocator, "{s}{s}", .{ base[0..pos], normalized_src });
}
// not sure what to do here...error? Just let it fallthrough for now.
}
if (std.mem.lastIndexOfScalar(u8, base[protocol_end..], '/')) |index| {
const last_slash_pos = index + protocol_end;
@@ -216,41 +227,51 @@ test "URL: resolve size" {
test "URL: Stitching Base & Src URLs (Basic)" {
const allocator = testing.allocator;
const base = "https://www.google.com/xyz/abc/123";
const base = "https://lightpanda.io/xyz/abc/123";
const src = "something.js";
const result = try URL.stitch(allocator, src, base, .{});
defer allocator.free(result);
try testing.expectString("https://www.google.com/xyz/abc/something.js", result);
try testing.expectString("https://lightpanda.io/xyz/abc/something.js", result);
}
test "URL: Stitching Base & Src URLs (Just Ending Slash)" {
const allocator = testing.allocator;
const base = "https://www.google.com/";
const base = "https://lightpanda.io/";
const src = "something.js";
const result = try URL.stitch(allocator, src, base, .{});
defer allocator.free(result);
try testing.expectString("https://www.google.com/something.js", result);
try testing.expectString("https://lightpanda.io/something.js", result);
}
test "URL: Stitching Base & Src URLs with leading slash" {
const allocator = testing.allocator;
const base = "https://www.google.com/";
const base = "https://lightpanda.io/";
const src = "/something.js";
const result = try URL.stitch(allocator, src, base, .{});
defer allocator.free(result);
try testing.expectString("https://www.google.com/something.js", result);
try testing.expectString("https://lightpanda.io/something.js", result);
}
test "URL: Stitching Base & Src URLs (No Ending Slash)" {
const allocator = testing.allocator;
const base = "https://www.google.com";
const base = "https://lightpanda.io";
const src = "something.js";
const result = try URL.stitch(allocator, src, base, .{});
defer allocator.free(result);
try testing.expectString("https://www.google.com/something.js", result);
try testing.expectString("https://lightpanda.io/something.js", result);
}
test "URL: Stitching Base with absolute src" {
const allocator = testing.allocator;
const base = "https://lightpanda.io/hello";
const src = "/abc/something.js";
const result = try URL.stitch(allocator, src, base, .{});
defer allocator.free(result);
try testing.expectString("https://lightpanda.io/abc/something.js", result);
}
test "URL: Stiching Base & Src URLs (Both Local)" {
@@ -275,11 +296,21 @@ test "URL: Stiching src as full path" {
test "URL: Stitching Base & Src URLs (empty src)" {
const allocator = testing.allocator;
const base = "https://www.google.com/xyz/abc/123";
const base = "https://lightpanda.io/xyz/abc/123";
const src = "";
const result = try URL.stitch(allocator, src, base, .{});
defer allocator.free(result);
try testing.expectString("https://www.google.com/xyz/abc/123", result);
try testing.expectString("https://lightpanda.io/xyz/abc/123", result);
}
test "URL: Stitching dotslash" {
const allocator = testing.allocator;
const base = "https://lightpanda.io/hello/";
const src = "./something.js";
const result = try URL.stitch(allocator, src, base, .{});
defer allocator.free(result);
try testing.expectString("https://lightpanda.io/hello/something.js", result);
}
test "URL: concatQueryString" {