Compare commits

..

222 Commits

Author SHA1 Message Date
Muki Kiboigo
1dab607369 add set_pathname on URL 2025-07-09 15:35:02 -07:00
Pierre Tachoire
889c29a163 Merge pull request #871 from lightpanda-io/ws-http-max
ws: increase max http message from 2kb to 4kb
2025-07-09 15:13:50 -07:00
Pierre Tachoire
886c1370e7 ws: increase max http message from 2kb to 4kb 2025-07-09 15:02:40 -07:00
Karl Seguin
febcc0a673 Merge pull request #864 from lightpanda-io/link_href
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
add HTMLElementLink get/set href
2025-07-09 18:48:17 +08:00
Karl Seguin
da3fe6f7ea fix test 2025-07-09 17:41:05 +08:00
Karl Seguin
f612ce262f Update src/browser/html/elements.zig
Co-authored-by: Sjors <72333389+sjorsdonkers@users.noreply.github.com>
2025-07-09 16:16:45 +08:00
Karl Seguin
7f732c94da add HTMLElementLink get/set href 2025-07-09 13:28:32 +08:00
Karl Seguin
bdc49a65aa Merge pull request #859 from lightpanda-io/document_fragment_query_selector
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
Add querySelect and querySelectorAll to DocumentFragment
2025-07-09 10:25:35 +08:00
Karl Seguin
73d82dd0ba I guess we can't use the call_arena for querySelectorAll 2025-07-09 10:19:16 +08:00
Karl Seguin
dfa4403c8a arena -> call_arena for querySelectorAll 2025-07-09 10:11:26 +08:00
Karl Seguin
b8f3b19499 Merge pull request #857 from lightpanda-io/improved_native_proto
Improve prototype resolution for native types
2025-07-09 10:01:38 +08:00
Karl Seguin
448718d112 Merge pull request #858 from lightpanda-io/callback_with_new_this
Allow JS Callback to be called with a previously-unseen this.
2025-07-09 09:34:14 +08:00
Pierre Tachoire
6de55df4bc Merge pull request #856 from lightpanda-io/resize_observer
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 dummy ResizeObserver
2025-07-08 15:50:08 -07:00
Pierre Tachoire
189fe26667 Merge pull request #862 from lightpanda-io/macos-14
ci: use macos-14 for nightly builds
2025-07-08 15:49:47 -07:00
Pierre Tachoire
7230884116 ci: use macos-14 for nightly builds 2025-07-08 08:27:45 -07:00
Karl Seguin
d7fba81f8f Add querySelect and querySelectorAll to DocumentFragment 2025-07-08 19:24:35 +08:00
Karl Seguin
29ac13185c Allow JS Callback to be called with a previously-unseen this. 2025-07-08 19:17:59 +08:00
Karl Seguin
3a49ee83ce Improve prototype resolution for native types
Prototype resolution of Zig types previously had 2 limitations (bug?). The first
was that the Zig prototype chain could only be 1 deep. You couldn't do A->B->C
where each of those was a Zig type (but you could do A->B->C->D->E ... so long
as every other type was a C opaque value).

The other limitation was that Zig prototypes only worked when the nested field
was directly embedded in the struct (i.e. not a pointer). So you could do:

```zig
const X = struct {
   proto: XParent,
};
```

But not:

```zig
const X = struct {
   proto: *XParent,
};
```

This addresses both limitations. The first issue is solved by keeping track
of the cumulative offset
2025-07-08 18:37:24 +08:00
Karl Seguin
95cbbc3b45 add dummy ResizeObserver 2025-07-08 18:35:25 +08:00
Karl Seguin
2a5c7d139f Merge pull request #855 from lightpanda-io/zig_fmt
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
zig fmt
2025-07-08 18:34:14 +08:00
Karl Seguin
b74863873b zig fmt 2025-07-08 18:28:21 +08:00
Karl Seguin
7b46fe9cc8 Merge pull request #848 from lightpanda-io/fix_insecure_forward_proxy
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
Fix non-tls forward-proxy
2025-07-08 09:52:23 +08:00
Karl Seguin
afc8c69a82 Merge pull request #854 from lightpanda-io/remove_debug_log
remove std.debug.print
2025-07-08 09:39:19 +08:00
Karl Seguin
38bbad6e88 Revert "fix secure connection logic"
This reverts commit b6132f2497.
2025-07-08 09:33:53 +08:00
Karl Seguin
1df47fd415 remove std.debug.print 2025-07-08 09:33:19 +08:00
Pierre Tachoire
faf21c5fff Merge pull request #853 from lightpanda-io/typo-fix
typo fix
2025-07-07 17:24:28 -07:00
Karl Seguin
2aee580795 Merge pull request #849 from lightpanda-io/mutation_observer_loop
Rework MutationObserver callback.
2025-07-08 08:15:02 +08:00
Pierre Tachoire
404c027546 typo fix 2025-07-07 17:14:52 -07:00
Karl Seguin
04e59c6df2 Merge pull request #850 from lightpanda-io/set_attribute_value
Attribute.set_value uses element, if possible
2025-07-08 08:14:52 +08:00
Karl Seguin
835042b794 Merge pull request #851 from lightpanda-io/add_event_listener_signal
Add support for the signal option of addEventListener
2025-07-08 08:14:38 +08:00
Pierre Tachoire
907490e266 Merge pull request #852 from lightpanda-io/katie-lpd-patch-1
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
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Update README.md
2025-07-07 17:01:09 -07:00
Pierre Tachoire
80fe167646 Update README.md 2025-07-07 17:00:54 -07:00
katie-lpd
d30631f991 Apply suggestions from code review
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-07-07 16:59:07 -07:00
katie-lpd
8956ab85f9 Update README.md 2025-07-07 16:50:32 -07:00
Karl Seguin
07693e54af Add support for the signal option of addEventListener 2025-07-07 20:56:19 +08:00
Karl Seguin
b6132f2497 fix secure connection logic 2025-07-07 19:56:21 +08:00
Karl Seguin
b3fe3d02c9 Attribute.set_value uses element, if possible
Only when setAttribute is called directly on the element, does libdom raise
a `DOMAttrModified` event (which MutationObserver uses).

From what I can tell, libdom's element set attribute _does_ rely on the
underlying attribute set value, so the behavior should be pretty close, it just
does extra things on top of that.
2025-07-07 19:47:17 +08:00
Karl Seguin
e880b18bb1 Rework MutationObserver callback.
Previously, MutationObserver callbacks where called using the `jsCallScopeEnd`
mechanism. This was slow and resulted in records split in a way that callers
might not expect. `jsCallScopeEnd` has been removed.

The new approach uses the loop.timeout mechanism, much like a window.setTimeout
and only registers a timeout when events have been handled. It should perform
much better.

Exactly how MutationRecords are supposed to be grouped is still a mystery to me.
This new grouping is still wrong in many cases (according to WPT), but appears
slightly less wrong; I'm pretty hopeful clients don't really have hard-coded
expectations for this though.

Also implement the attributeFilter option of MutationObserver. (Github)
2025-07-07 19:29:10 +08:00
Karl Seguin
74a299eef7 Fix non-tls forward-proxy 2025-07-07 11:03:04 +08:00
Karl Seguin
300428ddfb Merge pull request #840 from lightpanda-io/xhr_readystatechange
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 readystate change event to XHR
2025-07-06 08:59:19 +08:00
Pierre Tachoire
1c27f8251e Merge pull request #846 from lightpanda-io/e2e-draft
ci: don't run 2e2 on draft
2025-07-05 16:46:04 -07:00
Pierre Tachoire
92badd3722 ci: don't run 2e2 on draft 2025-07-05 14:22:21 -07:00
Karl Seguin
8a80f0b3dd Merge pull request #843 from lightpanda-io/empty_anchor_fix
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
An empty anchor should return empty strings for its getters
2025-07-04 23:43:10 +08:00
Karl Seguin
fcc74b63d3 correct comment 2025-07-04 23:17:48 +08:00
Karl Seguin
d7155e6662 An empty anchor should return empty strings for its getters
document.createElement('a').host  or .href or .. should return an empty string.

However, URL.constructor(document.createElement('a')) should fail.

Because HTMLAnchorElement uses URL.constructor, we have the wrong behavior.

This adds a guard for an empty anchor. This might not cover all of the cases
which are valid for an anchor but invalid for a URL.constructor, but it's
the most common.
2025-07-04 19:23:19 +08:00
Karl Seguin
42c3841639 Merge pull request #842 from lightpanda-io/fix_elementFromPoint_crash
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
Rely on js.zig for float->int translation
2025-07-04 19:12:46 +08:00
Karl Seguin
c331713401 set correct state on xhr.abort and send correct events 2025-07-04 19:12:26 +08:00
Karl Seguin
002d9c1747 Merge pull request #841 from lightpanda-io/scroll_events
make window.scrollTo triggers scroll and scrollend events
2025-07-04 19:00:28 +08:00
Karl Seguin
2885ceceb1 document use of i32 2025-07-04 18:55:14 +08:00
sjorsdonkers
22a644ba01 rename tls_in_tls to tlsproxy 2025-07-04 10:00:22 +02:00
sjorsdonkers
bab120a75d secure changes 2025-07-04 10:00:22 +02:00
Francis Bouvier
7a07c82f06 https-proxy: update upstream tls.zig 2025-07-04 10:00:22 +02:00
sjorsdonkers
e881d2f6cf tls proxy tweaks 2025-07-04 10:00:22 +02:00
Francis Bouvier
c8d003a08f https-proxy: update tls.zig 2025-07-04 10:00:22 +02:00
Francis Bouvier
e2cc404571 Handle TLS proxy, both for HTTP and HTTPS (tls in tls) endpoints 2025-07-04 10:00:22 +02:00
sjorsdonkers
be71eaae47 TLS connect proxy WIP 2025-07-04 10:00:22 +02:00
Karl Seguin
ed31a452b2 Rely on js.zig for float->int translation
Not only does this ensure compatibility with browsers, it doesn't crash when
the value is NaN of Infinity.
2025-07-04 11:34:34 +08: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
3d17c531d7 make window.scrollTo triggers scroll and scrollend events 2025-07-03 19:37:07 +08:00
Karl Seguin
dfe90243d6 Add readystate change event to XHR
Deal with non-node current target crashing. Builds ontop of abort_signal, but
with the new event-target specific union, I think this will work in for all
future cases.
2025-07-03 19:32:24 +08: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
Karl Seguin
9b35736be3 Merge pull request #786 from lightpanda-io/custom-elements
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 CustomElementRegistry
2025-06-17 15:53:34 +08:00
Karl Seguin
9f54cb35f4 remove unused import and debug stmt 2025-06-17 08:20:11 +08:00
Karl Seguin
329bffb127 Fix non-probing of union array failure
Probing a union match for an possible value should rarely hard-fail. Instead,
it should return an .{.invalid = {}} response to let the prober decide how to
proceed. This fixes a hard-fail when a JS value fails to probe as an array.

Also, add :modal pseudo-class.

(both issues came up looking at github integration)
2025-06-17 08:19:01 +08:00
Karl Seguin
e2542f41b5 Improve build and test speed
Test speed has been improved only slightly by tweaking a 2-second running tests.

Build has been improved by:
1 - moving logFunctionCallError out of js.Caller and to a standalone function
2 - removing some non-generic code from the generic portions of the logger

Caller.getter and Caller.setter have been removed in favor or calling
Caller.method. This wasn't previously possible - prior to our v8 upgrade, they
had different signatures.

Also removed a largely unused parser/str.zig file.
2025-06-17 08:19:01 +08:00
Karl Seguin
efc7b9d4a5 Add comment explaining why we're walking the form the way we are 2025-06-17 08:19:01 +08:00
Karl Seguin
72915760c4 Use css.querySelectorAll to find form elements
Libdom's formGetCollection doesn't work (like I would expect) for dynamically
added elements.

For example, given:

```
let el = document.createElement('input');
document.getElementsByTagName('form')[0].append(el);
```

(and assume the page has a form), I'd expect `el.form` to be equal to the form
the input was added to. Instead, it's null. This is a problem given that
`dom_html_form_element_get_elements` uses the element's `form` attribute to
"collect" the elements.

This uses our existing querySelector to find the form elements.
2025-06-17 08:19:01 +08:00
Karl Seguin
e9d7a946c5 Merge pull request #784 from lightpanda-io/union_param_array_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
Fix non-probing of union array failure
2025-06-17 08:08:04 +08:00
Karl Seguin
714e5e0456 Merge pull request #785 from lightpanda-io/build_and_test_speed
Improve build and test speed
2025-06-17 08:07:43 +08:00
Karl Seguin
26e8642aca Merge pull request #782 from lightpanda-io/form_support_dynamic_elements
Use css.querySelectorAll to find form elements
2025-06-17 08:07:30 +08:00
Karl Seguin
68dfb4ee86 fix custom elements when minified js is used 2025-06-16 07:47:53 -07:00
Karl Seguin
f1ff789334 implement custom elements - i think/hope 2025-06-16 07:45:49 -07:00
Muki Kiboigo
1f45d5b8e4 add CustomElementRegistry 2025-06-16 07:35:55 -07:00
Karl Seguin
c20052f314 Add comment explaining why we're walking the form the way we are 2025-06-16 19:56:19 +08:00
Karl Seguin
c28d87d59c Improve build and test speed
Test speed has been improved only slightly by tweaking a 2-second running tests.

Build has been improved by:
1 - moving logFunctionCallError out of js.Caller and to a standalone function
2 - removing some non-generic code from the generic portions of the logger

Caller.getter and Caller.setter have been removed in favor or calling
Caller.method. This wasn't previously possible - prior to our v8 upgrade, they
had different signatures.

Also removed a largely unused parser/str.zig file.
2025-06-16 19:50:13 +08:00
Karl Seguin
237ddcba9a Fix non-probing of union array failure
Probing a union match for an possible value should rarely hard-fail. Instead,
it should return an .{.invalid = {}} response to let the prober decide how to
proceed. This fixes a hard-fail when a JS value fails to probe as an array.

Also, add :modal pseudo-class.

(both issues came up looking at github integration)
2025-06-16 17:07:43 +08:00
Karl Seguin
eadb5b6461 Use css.querySelectorAll to find form elements
Libdom's formGetCollection doesn't work (like I would expect) for dynamically
added elements.

For example, given:

```
let el = document.createElement('input');
document.getElementsByTagName('form')[0].append(el);
```

(and assume the page has a form), I'd expect `el.form` to be equal to the form
the input was added to. Instead, it's null. This is a problem given that
`dom_html_form_element_get_elements` uses the element's `form` attribute to
"collect" the elements.

This uses our existing querySelector to find the form elements.
2025-06-16 13:49:34 +08:00
Karl Seguin
faebabe3c7 Merge pull request #781 from lightpanda-io/null-scriptname
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
handle null scriptname in stacktrace
2025-06-16 11:52:40 +08:00
Pierre Tachoire
02c510b07f upgrade zig-v8 2025-06-13 19:19:10 +02:00
Pierre Tachoire
63541970eb handle null scriptname in stack trace 2025-06-13 19:17:07 +02:00
Pierre Tachoire
a8a5605fe1 typo fix 2025-06-13 19:16:54 +02:00
sjorsdonkers
0c0ddc10ee rename scope jscontext
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
2025-06-13 10:30:50 +02:00
Karl Seguin
9bd5ff69ef Merge pull request #779 from lightpanda-io/waitForNavigation
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
Internal navigation should change the CDP loader-id
2025-06-13 09:38:33 +08:00
Karl Seguin
eadf351e82 Merge pull request #778 from lightpanda-io/terminate_execution
Terminate execution on internal navigation
2025-06-13 09:38:21 +08:00
Karl Seguin
e3afa294af Merge pull request #776 from lightpanda-io/fix_internal_navigation_deadlocks
Rework internal navigation to prevent deadlocking
2025-06-13 09:38:09 +08:00
Pierre Tachoire
582894cdc3 Merge pull request #780 from lightpanda-io/fix_loop_run_wait
Fix loop run (Page.wait)
2025-06-12 17:26:05 +02:00
Karl Seguin
2788c36ca6 Fix loop run (Page.wait)
In https://github.com/lightpanda-io/browser/pull/767 I tried to call loop.run
from within a loop.run (spoiler, it didn't work), in order to make sure aborted
connections were properly cleaned up before starting a new navigation. That
resulted in having loop.run no longer wait for timeouts for fear of having to
wait on a long timeout. The ended up breaking page.wait (used in the fetch
command).

This commit brings back the original behavior where loop.run() waits for all
completions. Which is now safe to do since the nested loop.run() call has
been removed.
2025-06-12 23:01:07 +08:00
Karl Seguin
872a9d393d Internal navigation should change the CDP loader-id
This is one of the ways that puppeteer knows that navigation happened
and is needed to support `waitForNavigation` which compares the
existing loader-id with the new one, so it has to change.

Also, fix a crash that could happen if CDP disconnects while
connections are being aborted.
2025-06-12 19:11:26 +08:00
Karl Seguin
b1ca242d89 Terminate execution on internal navigation
Currently, if there's an internal navigation event, we continue to process the
page normally. This has negative performance implication, and can result in
user-scripts producing unexpected results.

For example, imagine a page with a script that does.

```js
if (x) {
   form.submit();
}

reloadProduct();
```

The call to `form.submit()` should stop the script from executing. And, if the
page has 10 other <script> tags after this, they shouldn't be loaded nor
executed.

This code terminates the execution of the current script on an internal
navigation event, and stops the rest of the page from load.

While I believe this creates a more "correct" behavior, it also introduces new
edge cases. There's no a period of time, between the termination being stopped
and then being resumed, where executing code is not safe.
2025-06-12 16:38:48 +08:00
Karl Seguin
97c769e805 Rework internal navigation to prevent deadlocking
The mix of sync and async HTTP requests requires care to avoid deadlocks.
Previously, it was possible for async requests to use up all available HTTP
state objects duration a navigation flow (either directly, or via an internal
redirect (e.g. click, submit, ...)). This would block the navigation, which,
because everything is single thread, would block the I/O loop, resulting in a
deadlock.

The correct solution seems to be to remove all synchronous I/O. And I tried to
do that, but I ran into a wall with module-loading, which is initiated from V8.
V8 says "give me the source for this module", and I don't see a great way to
tell it: wait a bit.

So I went back to trying to make this work with the hybrid model, despite last
weeks failures to get it to work. I changed two things:

1 - The http client will only directly initiate an async request if there's
    at least 2 free state objects available (1 for the request, and leaving 1
    free for any synchronous requests)

2 - Delayed navigation retries until there's at least 1 free http state object
    available.

Commits from last week did help with this. First, we're now guaranteed to have
a single sync-request at a time (previously, we could have had 2). Secondly,
the async connection is now async end-to-end (previously, it could have blocked
on an empty state pool).

We could probably make this a bit more obviously by reserving 1 state object
for synchronous requests. But, since the long term solution is probably having
no synchronous requests, I'm happy with anything that lets me move past this
issue.
2025-06-12 12:34:51 +08:00
Karl Seguin
0de33b36f8 Merge pull request #773 from lightpanda-io/keydown_handling
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 basic support for key events
2025-06-12 12:32:09 +08:00
Karl Seguin
cf39bdc7f7 use inputGetType and add buttonGetType 2025-06-12 09:46:02 +08:00
Karl Seguin
34b49498c9 Making sure that the optional parameters have defaults
Co-authored-by: Sjors <72333389+sjorsdonkers@users.noreply.github.com>
2025-06-12 09:46:01 +08:00
Karl Seguin
3a4bd00020 Ignore dead keys
Submit form on "Enter" within text input.

Convert "Enter" to "\n" in textarea.
2025-06-12 09:46:01 +08:00
Karl Seguin
effd07d8c0 Add basic support for key events
Support CDP's Input.dispatchKeyEvent and DOM key events. Currently only
keydown is supported and expects every key to be a displayable character.

It turns out that manipulating the DOM via key events isn't great because the
behavior really depends on the cursor. So, to do this more accurately, we'd
have to introduce some concept of a cursor.

Personally, I don't think we'll run into many pages that are purposefully
using keyboard events. But driver (puppeteer/playwright) scripts might be
another issue.
2025-06-12 09:45:59 +08:00
sjorsdonkers
d9ce89ab31 import html_element
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
2025-06-10 11:28:28 +02:00
Karl Seguin
5483c52227 Merge pull request #771 from lightpanda-io/http_request_fail
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
Emit http_request_fail notification
2025-06-09 15:22:46 +08:00
Karl Seguin
2b48902f1b Emit http_request_fail notification
CDP translate this into a Network.loadingFailed. This is necessary to make sure
every Network.requestWillBeSent is paired with either a Network.loadingFailed
or a Network.responseReceived.
2025-06-06 19:15:47 +08:00
88 changed files with 15910 additions and 12203 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 }}
@@ -98,7 +98,7 @@ jobs:
ARCH: aarch64
OS: macos
runs-on: macos-latest
runs-on: macos-14
timeout-minutes: 15
steps:
@@ -136,7 +136,7 @@ jobs:
ARCH: x86_64
OS: macos
runs-on: macos-13
runs-on: macos-14
timeout-minutes: 15
steps:

View File

@@ -45,6 +45,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
# Don't run the CI with draft PR.
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v4
with:
@@ -65,56 +68,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 +100,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 +140,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

@@ -18,7 +18,7 @@ Lightpanda is the open-source browser made for headless usage:
- Javascript execution
- Support of Web APIs (partial, WIP)
- Compatible with Playwright[^1], Puppeteer through CDP (WIP)
- Compatible with Playwright[^1], Puppeteer, chromedp through CDP
Fast web automation for AI agents, LLM training, scraping and testing:
@@ -41,7 +41,8 @@ Due to the nature of Playwright, a script that works with the current version of
## Quick start
### Install from the nightly builds
### Install
**Install from the nightly builds**
You can download the last binary from the [nightly
builds](https://github.com/lightpanda-io/browser/releases/tag/nightly) for
@@ -64,6 +65,17 @@ chmod a+x ./lightpanda
The Lightpanda browser is compatible to run on windows inside WSL. Follow the Linux instruction for installation from a WSL terminal.
It is recommended to install clients like Puppeteer on the Windows host.
**Install from Docker**
Lightpanda provides [official Docker
images](https://hub.docker.com/r/lightpanda/browser) for both Linux amd64 and
arm64 architectures.
The following command fetches the Docker image and starts a new container exposing Lightpanda's CDP server on port `9222`.
The `--privileged` option is required because the browser requires `io_uring` syscalls which are blocked by default by Docker.
```console
docker run -d --name lightpanda -p 9222:9222 --privileged lightpanda/browser:nightly
```
### Dump a URL
```console
@@ -124,21 +136,27 @@ By default, Lightpanda collects and sends usage telemetry. This can be disabled
## Status
Lightpanda is still a work in progress and is currently at a Beta stage.
:warning: You should expect most websites to fail or crash.
Lightpanda is in Beta and currently a work in progress. Stability and coverage are improving and many websites now work.
You may still encounter errors or crashes. Please open an issue with specifics if so.
Here are the key features we have implemented:
- [x] HTTP loader
- [x] HTTP loader
- [x] HTML parser and DOM tree (based on Netsurf libs)
- [x] Javascript support (v8)
- [x] Basic DOM APIs
- [x] DOM APIs
- [x] Ajax
- [x] XHR API
- [x] Fetch API
- [x] Fetch API (polyfill)
- [x] DOM dump
- [x] Basic CDP/websockets server
- [x] CDP/websockets server
- [x] Click
- [x] Input form
- [x] Cookies
- [x] Custom HTTP headers
- [ ] Proxy support
- [ ] Network interception
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.

View File

@@ -5,16 +5,16 @@
.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/55845f755d9e2e821458ea55693f85c737cd0c7a.tar.gz",
.hash = "tls-0.1.0-ER2e0m43BQAshi8ixj1qf3w2u2lqKtXtkrxUJ4AGZDcl",
},
.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/bf7ba696b3e819195f8fc349b2778c59aab81a61.tar.gz",
.hash = "v8-0.0.0-xddH6xm3AwA287seRdWB_mIjZ9_Ayk-81z9uwWoag7Er",
.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

@@ -605,6 +605,7 @@ pub const Parser = struct {
.after, .backdrop, .before, .cue, .first_letter => return .{ .pseudo_element = pseudo_class },
.first_line, .grammar_error, .marker, .placeholder => return .{ .pseudo_element = pseudo_class },
.selection, .spelling_error => return .{ .pseudo_element = pseudo_class },
.modal => return .{ .pseudo_element = pseudo_class },
}
}

View File

@@ -98,6 +98,7 @@ pub const PseudoClass = enum {
placeholder,
selection,
spelling_error,
modal,
pub const Error = error{
InvalidPseudoClass,
@@ -154,6 +155,7 @@ pub const PseudoClass = enum {
if (std.ascii.eqlIgnoreCase(s, "placeholder")) return .placeholder;
if (std.ascii.eqlIgnoreCase(s, "selection")) return .selection;
if (std.ascii.eqlIgnoreCase(s, "spelling-error")) return .spelling_error;
if (std.ascii.eqlIgnoreCase(s, "modal")) return .modal;
return Error.InvalidPseudoClass;
}
};

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),
@@ -85,7 +79,7 @@ pub const CSSStyleDeclaration = struct {
return self.order.items.len;
}
pub fn get_parentRule() ?CSSRule {
pub fn get_parentRule(_: *const CSSStyleDeclaration) ?CSSRule {
return null;
}

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

@@ -15,7 +15,6 @@
//
// 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");
const Node = @import("node.zig").Node;
@@ -47,7 +46,14 @@ pub const Attr = struct {
}
pub fn set_value(self: *parser.Attribute, v: []const u8) !?[]const u8 {
try parser.attributeSetValue(self, v);
if (try parser.attributeGetOwnerElement(self)) |el| {
// if possible, go through the element, as that triggers a
// DOMAttrModified event (which MutationObserver cares about)
const name = try parser.attributeGetName(self);
try parser.elementSetAttribute(el, name, v);
} else {
try parser.attributeSetValue(self, v);
}
return v;
}

View File

@@ -101,7 +101,7 @@ pub const CharacterData = struct {
// netsurf's CharacterData (text, comment) doesn't implement the
// dom_node_get_attributes and thus will crash if we try to call nodeIsEqualNode.
pub fn _isEqualNode(self: *parser.CharacterData, other_node: *parser.Node) !bool {
if (try parser.nodeType(@ptrCast(self)) != try parser.nodeType(other_node)) {
if (try parser.nodeType(@alignCast(@ptrCast(self))) != try parser.nodeType(other_node)) {
return false;
}

View File

@@ -18,6 +18,7 @@
const std = @import("std");
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
@@ -31,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;
@@ -120,9 +122,28 @@ pub const Document = struct {
return try Element.toInterface(e);
}
pub fn _createElement(self: *parser.Document, tag_name: []const u8) !ElementUnion {
const e = try parser.documentCreateElement(self, tag_name);
return try Element.toInterface(e);
const CreateElementResult = union(enum) {
element: ElementUnion,
custom: Env.JsObject,
};
pub fn _createElement(self: *parser.Document, tag_name: []const u8, page: *Page) !CreateElementResult {
const custom_element = page.window.custom_elements._get(tag_name) orelse {
const e = try parser.documentCreateElement(self, tag_name);
return .{ .element = try Element.toInterface(e) };
};
var result: Env.Function.Result = undefined;
const js_obj = custom_element.newInstance(&result) catch |err| {
log.fatal(.user_script, "newInstance error", .{
.err = result.exception,
.stack = result.stack,
.tag_name = tag_name,
.source = "createElement",
});
return err;
};
return .{ .custom = js_obj };
}
pub fn _createElementNS(self: *parser.Document, ns: []const u8, tag_name: []const u8) !ElementUnion {
@@ -216,7 +237,7 @@ pub const Document = struct {
pub fn _querySelector(self: *parser.Document, selector: []const u8, page: *Page) !?ElementUnion {
if (selector.len == 0) return null;
const n = try css.querySelector(page.arena, parser.documentToNode(self), selector);
const n = try css.querySelector(page.call_arena, parser.documentToNode(self), selector);
if (n == null) return null;
@@ -243,17 +264,23 @@ pub const Document = struct {
return try TreeWalker.init(root, what_to_show, filter);
}
pub fn get_activeElement(self: *parser.Document, page: *Page) !?ElementUnion {
const state = try page.getOrCreateNodeState(@ptrCast(self));
if (state.active_element) |ae| {
return try Element.toInterface(ae);
pub fn getActiveElement(self: *parser.Document, page: *Page) !?*parser.Element {
if (page.getNodeState(@alignCast(@ptrCast(self)))) |state| {
if (state.active_element) |ae| {
return ae;
}
}
if (try parser.documentHTMLBody(page.window.document)) |body| {
return try Element.toInterface(@ptrCast(body));
return @alignCast(@ptrCast(body));
}
return get_documentElement(self);
return try parser.documentGetDocumentElement(self);
}
pub fn get_activeElement(self: *parser.Document, page: *Page) !?ElementUnion {
const ae = (try getActiveElement(self, page)) orelse return null;
return try Element.toInterface(ae);
}
// TODO: some elements can't be focused, like if they're disabled
@@ -261,9 +288,13 @@ pub const Document = struct {
// we could look for the "disabled" attribute, but that's only meaningful
// on certain types, and libdom's vtable doesn't seem to expose this.
pub fn setFocus(self: *parser.Document, e: *parser.ElementHTML, page: *Page) !void {
const state = try page.getOrCreateNodeState(@ptrCast(self));
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

@@ -16,8 +16,12 @@
// 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 css = @import("css.zig");
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const NodeList = @import("nodelist.zig").NodeList;
const Element = @import("element.zig").Element;
const ElementUnion = @import("element.zig").Union;
const Node = @import("node.zig").Node;
@@ -53,6 +57,20 @@ pub const DocumentFragment = struct {
pub fn _replaceChildren(self: *parser.DocumentFragment, nodes: []const Node.NodeOrText) !void {
return Node.replaceChildren(parser.documentFragmentToNode(self), nodes);
}
pub fn _querySelector(self: *parser.DocumentFragment, selector: []const u8, page: *Page) !?ElementUnion {
if (selector.len == 0) return null;
const n = try css.querySelector(page.call_arena, parser.documentFragmentToNode(self), selector);
if (n == null) return null;
return try Element.toInterface(parser.nodeToElement(n.?));
}
pub fn _querySelectorAll(self: *parser.DocumentFragment, selector: []const u8, page: *Page) !NodeList {
return css.querySelectorAll(page.arena, parser.documentFragmentToNode(self), selector);
}
};
const testing = @import("../../testing.zig");
@@ -71,4 +89,23 @@ 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" },
.{ "document.querySelector('.hello')", "null" },
.{ "document.querySelectorAll('.hello').length", "0" },
.{ "document.querySelector('#x').id", "x" },
.{ "document.querySelectorAll('#x')[0].id", "x" },
}, .{});
}

View File

@@ -23,11 +23,13 @@ const NamedNodeMap = @import("namednodemap.zig").NamedNodeMap;
const DOMTokenList = @import("token_list.zig");
const NodeList = @import("nodelist.zig");
const Node = @import("node.zig");
const ResizeObserver = @import("resize_observer.zig");
const MutationObserver = @import("mutation_observer.zig");
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,
@@ -39,9 +41,13 @@ pub const Interfaces = .{
NodeList.Interfaces,
Node.Node,
Node.Interfaces,
ResizeObserver.Interfaces,
MutationObserver.Interfaces,
IntersectionObserver.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 {
@@ -331,7 +335,7 @@ pub const Element = struct {
pub fn _querySelector(self: *parser.Element, selector: []const u8, page: *Page) !?Union {
if (selector.len == 0) return null;
const n = try css.querySelector(page.arena, parser.elementToNode(self), selector);
const n = try css.querySelector(page.call_arena, parser.elementToNode(self), selector);
if (n == null) return null;
@@ -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

@@ -23,26 +23,42 @@ const Page = @import("../page.zig").Page;
const EventHandler = @import("../events/event.zig").EventHandler;
const DOMException = @import("exceptions.zig").DOMException;
const Nod = @import("node.zig");
const nod = @import("node.zig");
// EventTarget interfaces
pub const Union = Nod.Union;
pub const Union = union(enum) {
node: nod.Union,
xhr: *@import("../xhr/xhr.zig").XMLHttpRequest,
};
// EventTarget implementation
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 };
return .{ .node = .{ .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 .{ .node = .{ .AbortSignal = @fieldParentPtr("proto", @as(*parser.EventTargetTBase, @ptrCast(et))) } };
},
.xhr_event => {
const XMLHttpRequestEventTarget = @import("../xhr/event_target.zig").XMLHttpRequestEventTarget;
const base: *XMLHttpRequestEventTarget = @fieldParentPtr("base", @as(*parser.EventTargetTBase, @ptrCast(et)));
return .{ .xhr = @fieldParentPtr("proto", base) };
},
else => {
return .{ .node = try nod.Node.toInterface(@as(*parser.Node, @ptrCast(et))) };
},
}
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

@@ -22,6 +22,7 @@ const Allocator = std.mem.Allocator;
const log = @import("../../log.zig");
const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const Loop = @import("../../runtime/loop.zig").Loop;
const Env = @import("../env.zig").Env;
const NodeList = @import("nodelist.zig").NodeList;
@@ -35,25 +36,37 @@ const Walker = @import("../dom/walker.zig").WalkerChildren;
// WEB IDL https://dom.spec.whatwg.org/#interface-mutationobserver
pub const MutationObserver = struct {
loop: *Loop,
cbk: Env.Function,
arena: Allocator,
connected: bool,
scheduled: bool,
loop_node: Loop.CallbackNode,
// List of records which were observed. When the scopeEnds, we need to
// List of records which were observed. When the call scope ends, we need to
// execute our callback with it.
observed: std.ArrayListUnmanaged(*MutationRecord),
observed: std.ArrayListUnmanaged(MutationRecord),
pub fn constructor(cbk: Env.Function, page: *Page) !MutationObserver {
return .{
.cbk = cbk,
.loop = page.loop,
.observed = .{},
.connected = true,
.scheduled = false,
.arena = page.arena,
.loop_node = .{ .func = callback },
};
}
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?MutationObserverInit) !void {
const options = options_ orelse MutationObserverInit{};
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?Options) !void {
const arena = self.arena;
var options = options_ orelse Options{};
if (options.attributeFilter.len > 0) {
options.attributeFilter = try arena.dupe([]const u8, options.attributeFilter);
}
const observer = try self.arena.create(Observer);
const observer = try arena.create(Observer);
observer.* = .{
.node = node,
.options = options,
@@ -102,30 +115,34 @@ pub const MutationObserver = struct {
}
}
pub fn jsCallScopeEnd(self: *MutationObserver) void {
const record = self.observed.items;
if (record.len == 0) {
fn callback(node: *Loop.CallbackNode, _: *?u63) void {
const self: *MutationObserver = @fieldParentPtr("loop_node", node);
if (self.connected == false) {
self.scheduled = true;
return;
}
self.scheduled = false;
const records = self.observed.items;
if (records.len == 0) {
return;
}
defer self.observed.clearRetainingCapacity();
for (record) |r| {
const records = [_]MutationRecord{r.*};
var result: Env.Function.Result = undefined;
self.cbk.tryCall(void, .{records}, &result) catch {
log.debug(.user_script, "callback error", .{
.err = result.exception,
.stack = result.stack,
.source = "mutation observer",
});
};
}
var result: Env.Function.Result = undefined;
self.cbk.tryCall(void, .{records}, &result) catch {
log.debug(.user_script, "callback error", .{
.err = result.exception,
.stack = result.stack,
.source = "mutation observer",
});
};
}
// TODO
pub fn _disconnect(_: *MutationObserver) !void {
// TODO unregister listeners.
pub fn _disconnect(self: *MutationObserver) !void {
self.connected = false;
}
// TODO
@@ -182,31 +199,27 @@ pub const MutationRecord = struct {
}
};
const MutationObserverInit = struct {
const Options = struct {
childList: bool = false,
attributes: bool = false,
characterData: bool = false,
subtree: bool = false,
attributeOldValue: bool = false,
characterDataOldValue: bool = false,
// TODO
// attributeFilter: [][]const u8,
attributeFilter: [][]const u8 = &.{},
fn attr(self: MutationObserverInit) bool {
return self.attributes or self.attributeOldValue;
fn attr(self: Options) bool {
return self.attributes or self.attributeOldValue or self.attributeFilter.len > 0;
}
fn cdata(self: MutationObserverInit) bool {
fn cdata(self: Options) bool {
return self.characterData or self.characterDataOldValue;
}
};
const Observer = struct {
node: *parser.Node,
options: MutationObserverInit,
// record of the mutation, all observed changes in 1 call are batched
record: ?MutationRecord = null,
options: Options,
// reference back to the MutationObserver so that we can access the arena
// and batch the mutation records.
@@ -214,19 +227,34 @@ const Observer = struct {
event_node: parser.EventNode,
fn appliesTo(o: *const Observer, target: *parser.Node) bool {
fn appliesTo(
self: *const Observer,
target: *parser.Node,
event_type: MutationEventType,
event: *parser.MutationEvent,
) !bool {
if (event_type == .DOMAttrModified and self.options.attributeFilter.len > 0) {
const attribute_name = try parser.mutationEventAttributeName(event);
for (self.options.attributeFilter) |needle| blk: {
if (std.mem.eql(u8, attribute_name, needle)) {
break :blk;
}
}
return false;
}
// mutation on any target is always ok.
if (o.options.subtree) {
if (self.options.subtree) {
return true;
}
// if target equals node, alway ok.
if (target == o.node) {
if (target == self.node) {
return true;
}
// no subtree, no same target and no childlist, always noky.
if (!o.options.childList) {
if (!self.options.childList) {
return false;
}
@@ -234,7 +262,7 @@ const Observer = struct {
const walker = Walker{};
var next: ?*parser.Node = null;
while (true) {
next = walker.get_next(o.node, next) catch break orelse break;
next = walker.get_next(self.node, next) catch break orelse break;
if (next.? == target) {
return true;
}
@@ -258,27 +286,22 @@ const Observer = struct {
break :blk parser.eventTargetToNode(event_target);
};
if (self.appliesTo(node) == false) {
return;
}
const mutation_event = parser.eventToMutationEvent(event);
const event_type = blk: {
const t = try parser.eventType(event);
break :blk std.meta.stringToEnum(MutationEventType, t) orelse return;
};
const arena = mutation_observer.arena;
if (self.record == null) {
self.record = .{
.target = self.node,
.type = event_type.recordType(),
};
try mutation_observer.observed.append(arena, &self.record.?);
if (try self.appliesTo(node, event_type, mutation_event) == false) {
return;
}
var record = &self.record.?;
const mutation_event = parser.eventToMutationEvent(event);
var record = MutationRecord{
.target = self.node,
.type = event_type.recordType(),
};
const arena = mutation_observer.arena;
switch (event_type) {
.DOMAttrModified => {
record.attribute_name = parser.mutationEventAttributeName(mutation_event) catch null;
@@ -302,6 +325,13 @@ const Observer = struct {
}
},
}
try mutation_observer.observed.append(arena, record);
if (mutation_observer.scheduled == false) {
mutation_observer.scheduled = true;
_ = try mutation_observer.loop.timeout(0, &mutation_observer.loop_node);
}
}
};
@@ -341,10 +371,10 @@ test "Browser.DOM.MutationObserver" {
\\ document.firstElementChild.setAttribute("foo", "bar");
\\ // ignored b/c it's about another target.
\\ document.firstElementChild.firstChild.setAttribute("foo", "bar");
\\ nb;
,
"1",
null,
},
.{ "nb", "1" },
.{ "mrs[0].type", "attributes" },
.{ "mrs[0].target == document.firstElementChild", "true" },
.{ "mrs[0].target.getAttribute('foo')", "bar" },
@@ -362,10 +392,10 @@ test "Browser.DOM.MutationObserver" {
\\ nb2++;
\\ }).observe(node, { characterData: true, characterDataOldValue: true });
\\ node.data = "foo";
\\ nb2;
,
"1",
null,
},
.{ "nb2", "1" },
.{ "mrs2[0].type", "characterData" },
.{ "mrs2[0].target == node", "true" },
.{ "mrs2[0].target.data", "foo" },
@@ -383,7 +413,24 @@ test "Browser.DOM.MutationObserver" {
\\ }).observe(document, { subtree:true,childList:true });
\\ node.innerText = "2";
,
"2",
null,
},
.{ "node.innerText", "a" },
}, .{});
try runner.testCases(&.{
.{
\\ var node = document.getElementById("para");
\\ var attrWatch = 0;
\\ new MutationObserver(() => {
\\ attrWatch++;
\\ }).observe(document, { attributeFilter: ["name"], subtree: true });
\\ node.setAttribute("id", "1");
,
null,
},
.{ "attrWatch", "0" },
.{ "node.setAttribute('name', 'other');", null },
.{ "attrWatch", "1" },
}, .{});
}

View File

@@ -134,5 +134,7 @@ test "Browser.DOM.NamedNodeMap" {
.{ "a['id'].name", "id" },
.{ "a['id'].value", "content" },
.{ "a['other']", "undefined" },
.{ "a[0].value = 'abc123'", null },
.{ "a[0].value", "abc123" },
}, .{});
}

View File

@@ -496,7 +496,7 @@ pub const Node = struct {
fn toNode(self: NodeOrText, doc: *parser.Document) !*parser.Node {
return switch (self) {
.node => |n| n,
.text => |txt| @ptrCast(try parser.documentCreateTextNode(doc, txt)),
.text => |txt| @alignCast(@ptrCast(try parser.documentCreateTextNode(doc, txt))),
};
}

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.2);
}
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

@@ -0,0 +1,54 @@
// 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 Env = @import("../env.zig").Env;
const parser = @import("../netsurf.zig");
pub const Interfaces = .{
ResizeObserver,
};
// WEB IDL https://drafts.csswg.org/resize-observer/#resize-observer-interface
pub const ResizeObserver = struct {
pub fn constructor(cbk: Env.Function) ResizeObserver {
_ = cbk;
return .{};
}
pub fn _observe(self: *const ResizeObserver, element: *parser.Element, options_: ?Options) void {
_ = self;
_ = element;
_ = options_;
return;
}
pub fn _unobserve(self: *const ResizeObserver, element: *parser.Element) void {
_ = self;
_ = element;
return;
}
// TODO
pub fn _disconnect(self: *ResizeObserver) void {
_ = self;
}
};
const Options = struct {
box: []const u8,
};

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;
// write the children
// TODO avoid recursion
try writeChildren(node, writer);
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,
@@ -33,6 +34,7 @@ const WebApis = struct {
@import("xhr/xhr.zig").Interfaces,
@import("xhr/form_data.zig").Interfaces,
@import("xmlserializer/xmlserializer.zig").Interfaces,
@import("webcomponents/webcomponents.zig").Interfaces,
});
};

View File

@@ -27,18 +27,15 @@ const Page = @import("../page.zig").Page;
const DOMException = @import("../dom/exceptions.zig").DOMException;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const EventTargetUnion = @import("../dom/event_target.zig").Union;
const AbortSignal = @import("../html/AbortController.zig").AbortSignal;
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 +55,11 @@ pub const Event = struct {
pub fn toInterface(evt: *parser.Event) !Union {
return switch (try parser.eventGetInternalType(evt)) {
.event => .{ .Event = evt },
.event, .abort_signal, .xhr_event => .{ .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 +78,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 {
@@ -178,7 +176,7 @@ pub const EventHandler = struct {
// that the listener won't call preventDefault() and thus can safely
// run the default as needed).
passive: ?bool,
signal: ?bool, // currently does nothing
signal: ?*AbortSignal, // currently does nothing
};
};
@@ -191,18 +189,14 @@ pub const EventHandler = struct {
) !?*EventHandler {
var once = false;
var capture = false;
var signal: ?*AbortSignal = null;
if (opts_) |opts| {
switch (opts) {
.capture => |c| capture = c,
.flags => |f| {
// Done this way so that, for common cases that _only_ set
// capture, i.e. {captrue: true}, it works.
// But for any case that sets any of the other flags, we
// error. If we don't error, this function call would succeed
// but the behavior might be wrong. At this point, it's
// better to be explicit and error.
if (f.signal orelse false) return error.NotImplemented;
once = f.once orelse false;
signal = f.signal orelse null;
capture = f.capture orelse false;
},
}
@@ -210,6 +204,28 @@ pub const EventHandler = struct {
const callback = (try listener.callback(target)) orelse return null;
if (signal) |s| {
const signal_target = parser.toEventTarget(AbortSignal, s);
const scb = try allocator.create(SignalCallback);
scb.* = .{
.target = target,
.capture = capture,
.callback_id = callback.id,
.typ = try allocator.dupe(u8, typ),
.signal_target = signal_target,
.signal_listener = undefined,
.node = .{ .func = SignalCallback.handle },
};
scb.signal_listener = try parser.eventTargetAddEventListener(
signal_target,
"abort",
&scb.node,
false,
);
}
// check if event target has already this listener
if (try parser.eventTargetHasListener(target, typ, capture, callback.id) != null) {
return null;
@@ -265,6 +281,50 @@ pub const EventHandler = struct {
}
};
const SignalCallback = struct {
typ: []const u8,
capture: bool,
callback_id: usize,
node: parser.EventNode,
target: *parser.EventTarget,
signal_target: *parser.EventTarget,
signal_listener: *parser.EventListener,
fn handle(node: *parser.EventNode, _: *parser.Event) void {
const self: *SignalCallback = @fieldParentPtr("node", node);
self._handle() catch |err| {
log.err(.app, "event signal handler", .{ .err = err });
};
}
fn _handle(self: *SignalCallback) !void {
const lst = try parser.eventTargetHasListener(
self.target,
self.typ,
self.capture,
self.callback_id,
);
if (lst == null) {
return;
}
try parser.eventTargetRemoveEventListener(
self.target,
self.typ,
lst.?,
self.capture,
);
// remove the abort signal listener itself
try parser.eventTargetRemoveEventListener(
self.signal_target,
"abort",
self.signal_listener,
false,
);
}
};
const testing = @import("../../testing.zig");
test "Browser.Event" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
@@ -370,5 +430,18 @@ test "Browser.Event" {
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "nb", "1" },
.{ "document.removeEventListener('count', cbk)", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; function cbk(event) { nb ++; }", null },
.{ "let ac = new AbortController()", null },
.{ "document.addEventListener('count', cbk, {signal: ac.signal})", null },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "ac.abort()", null },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "nb", "2" },
.{ "document.removeEventListener('count', cbk)", "undefined" },
}, .{});
}

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;
}
@@ -184,7 +188,7 @@ pub const HTMLDocument = struct {
}
pub fn get_readyState(self: *parser.DocumentHTML, page: *Page) ![]const u8 {
const state = try page.getOrCreateNodeState(@ptrCast(self));
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
return @tagName(state.ready_state);
}
@@ -229,19 +233,23 @@ pub const HTMLDocument = struct {
// Since LightPanda requires the client to know what they are clicking on we do not return the underlying element at this moment
// This can currenty only happen if the first pixel is clicked without having rendered any element. This will change when css properties are supported.
// This returns an ElementUnion instead of a *Parser.Element in case the element somehow hasn't passed through the js runtime yet.
pub fn _elementFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, page: *Page) !?ElementUnion {
const ix: i32 = @intFromFloat(@floor(x));
const iy: i32 = @intFromFloat(@floor(y));
const element = page.renderer.getElementAtPosition(ix, iy) orelse return null;
// While x and y should be f32, here we take i32 since that's what our
// "renderer" uses. By specifying i32 here, rather than f32 and doing the
// conversion ourself, we rely on v8's type conversion which is both more
// flexible (e.g. handles NaN) and will be more consistent with a browser.
pub fn _elementFromPoint(_: *parser.DocumentHTML, x: i32, y: i32, page: *Page) !?ElementUnion {
const element = page.renderer.getElementAtPosition(x, y) orelse return null;
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
return try Element.toInterface(element);
}
// Returns an array of all elements at the specified coordinates (relative to the viewport). The elements are ordered from the topmost to the bottommost box of the viewport.
pub fn _elementsFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, page: *Page) ![]ElementUnion {
const ix: i32 = @intFromFloat(@floor(x));
const iy: i32 = @intFromFloat(@floor(y));
const element = page.renderer.getElementAtPosition(ix, iy) orelse return &.{};
// While x and y should be f32, here we take i32 since that's what our
// "renderer" uses. By specifying i32 here, rather than f32 and doing the
// conversion ourself, we rely on v8's type conversion which is both more
// flexible (e.g. handles NaN) and will be more consistent with a browser.
pub fn _elementsFromPoint(_: *parser.DocumentHTML, x: i32, y: i32, page: *Page) ![]ElementUnion {
const element = page.renderer.getElementAtPosition(x, y) orelse return &.{};
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
var list: std.ArrayListUnmanaged(ElementUnion) = .empty;
@@ -263,7 +271,7 @@ pub const HTMLDocument = struct {
}
pub fn documentIsLoaded(self: *parser.DocumentHTML, page: *Page) !void {
const state = try page.getOrCreateNodeState(@ptrCast(self));
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
state.ready_state = .interactive;
const evt = try parser.eventCreate();
@@ -278,7 +286,7 @@ pub const HTMLDocument = struct {
}
pub fn documentIsComplete(self: *parser.DocumentHTML, page: *Page) !void {
const state = try page.getOrCreateNodeState(@ptrCast(self));
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
state.ready_state = .complete;
}
};
@@ -333,6 +341,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

@@ -16,6 +16,7 @@
// 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 generate = @import("../../runtime/generate.zig");
@@ -26,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;
@@ -112,11 +114,24 @@ pub const HTMLElement = struct {
pub const prototype = *Element;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
pub fn get_style(e: *parser.ElementHTML, page: *Page) !*CSSStyleDeclaration {
const state = try page.getOrCreateNodeState(@ptrCast(e));
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 "";
@@ -133,7 +148,7 @@ pub const HTMLElement = struct {
try Node.removeChildren(n);
// attach the text node.
_ = try parser.nodeAppendChild(n, @as(*parser.Node, @ptrCast(t)));
_ = try parser.nodeAppendChild(n, @as(*parser.Node, @alignCast(@ptrCast(t))));
}
pub fn _click(e: *parser.ElementHTML) !void {
@@ -174,6 +189,10 @@ pub const HTMLMediaElement = struct {
pub const Self = parser.MediaElement;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
// HTML elements
@@ -183,6 +202,10 @@ pub const HTMLUnknownElement = struct {
pub const Self = parser.Unknown;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
// https://html.spec.whatwg.org/#the-a-element
@@ -191,6 +214,10 @@ pub const HTMLAnchorElement = struct {
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
pub fn get_target(self: *parser.Anchor) ![]const u8 {
return try parser.anchorGetTarget(self);
}
@@ -244,8 +271,18 @@ pub const HTMLAnchorElement = struct {
return try parser.nodeSetTextContent(parser.anchorToNode(self), v);
}
inline fn url(self: *parser.Anchor, page: *Page) !URL {
return URL.constructor(.{ .element = @ptrCast(self) }, null, page); // TODO inject base url
fn url(self: *parser.Anchor, page: *Page) !URL {
// Although the URL.constructor union accepts an .{.element = X}, we
// can't use this here because the behavior is different.
// URL.constructor(document.createElement('a')
// should fail (a.href isn't a valid URL)
// But
// document.createElement('a').host
// should not fail, it should return an empty string
if (try parser.elementGetAttribute(@alignCast(@ptrCast(self)), "href")) |href| {
return URL.constructor(.{ .string = href }, null, page); // TODO inject base url
}
return .empty;
}
// TODO return a disposable string
@@ -428,144 +465,240 @@ pub const HTMLAppletElement = struct {
pub const Self = parser.Applet;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLAreaElement = struct {
pub const Self = parser.Area;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLAudioElement = struct {
pub const Self = parser.Audio;
pub const prototype = *HTMLMediaElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLBRElement = struct {
pub const Self = parser.BR;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLBaseElement = struct {
pub const Self = parser.Base;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLBodyElement = struct {
pub const Self = parser.Body;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLButtonElement = struct {
pub const Self = parser.Button;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLCanvasElement = struct {
pub const Self = parser.Canvas;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLDListElement = struct {
pub const Self = parser.DList;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLDataElement = struct {
pub const Self = parser.Data;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLDataListElement = struct {
pub const Self = parser.DataList;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLDialogElement = struct {
pub const Self = parser.Dialog;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLDirectoryElement = struct {
pub const Self = parser.Directory;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLDivElement = struct {
pub const Self = parser.Div;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLEmbedElement = struct {
pub const Self = parser.Embed;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLFieldSetElement = struct {
pub const Self = parser.FieldSet;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLFontElement = struct {
pub const Self = parser.Font;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLFrameElement = struct {
pub const Self = parser.Frame;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLFrameSetElement = struct {
pub const Self = parser.FrameSet;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLHRElement = struct {
pub const Self = parser.HR;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLHeadElement = struct {
pub const Self = parser.Head;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLHeadingElement = struct {
pub const Self = parser.Heading;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLHtmlElement = struct {
pub const Self = parser.Html;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLIFrameElement = struct {
pub const Self = parser.IFrame;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLImageElement = struct {
@@ -573,6 +706,10 @@ pub const HTMLImageElement = struct {
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
pub fn get_alt(self: *parser.Image) ![]const u8 {
return try parser.imageGetAlt(self);
}
@@ -613,6 +750,7 @@ pub const HTMLImageElement = struct {
pub const Factory = struct {
pub const js_name = "Image";
pub const subtype = .node;
pub const js_legacy_factory = true;
pub const prototype = *HTMLImageElement;
@@ -631,6 +769,10 @@ pub const HTMLInputElement = struct {
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
pub fn get_defaultValue(self: *parser.Input) ![]const u8 {
return try parser.inputGetDefaultValue(self);
}
@@ -719,114 +861,199 @@ pub const HTMLLIElement = struct {
pub const Self = parser.LI;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLLabelElement = struct {
pub const Self = parser.Label;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLLegendElement = struct {
pub const Self = parser.Legend;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLLinkElement = struct {
pub const Self = parser.Link;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
pub fn get_href(self: *parser.Link) ![]const u8 {
return try parser.linkGetHref(self);
}
pub fn set_href(self: *parser.Link, href: []const u8, page: *const Page) !void {
const full = try urlStitch(page.call_arena, href, page.url.raw, .{});
return try parser.linkSetHref(self, full);
}
};
pub const HTMLMapElement = struct {
pub const Self = parser.Map;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLMetaElement = struct {
pub const Self = parser.Meta;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLMeterElement = struct {
pub const Self = parser.Meter;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLModElement = struct {
pub const Self = parser.Mod;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLOListElement = struct {
pub const Self = parser.OList;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLObjectElement = struct {
pub const Self = parser.Object;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLOptGroupElement = struct {
pub const Self = parser.OptGroup;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLOptionElement = struct {
pub const Self = parser.Option;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLOutputElement = struct {
pub const Self = parser.Output;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLParagraphElement = struct {
pub const Self = parser.Paragraph;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLParamElement = struct {
pub const Self = parser.Param;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLPictureElement = struct {
pub const Self = parser.Picture;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLPreElement = struct {
pub const Self = parser.Pre;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLProgressElement = struct {
pub const Self = parser.Progress;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLQuoteElement = struct {
pub const Self = parser.Quote;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
// https://html.spec.whatwg.org/#the-script-element
@@ -835,6 +1062,10 @@ pub const HTMLScriptElement = struct {
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
pub fn get_src(self: *parser.Script) !?[]const u8 {
return try parser.elementGetAttribute(
parser.scriptToElt(self),
@@ -945,22 +1176,22 @@ pub const HTMLScriptElement = struct {
}
pub fn get_onload(self: *parser.Script, page: *Page) !?Env.Function {
const state = page.getNodeState(@ptrCast(self)) orelse return null;
const state = page.getNodeState(@alignCast(@ptrCast(self))) orelse return null;
return state.onload;
}
pub fn set_onload(self: *parser.Script, function: ?Env.Function, page: *Page) !void {
const state = try page.getOrCreateNodeState(@ptrCast(self));
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
state.onload = function;
}
pub fn get_onerror(self: *parser.Script, page: *Page) !?Env.Function {
const state = page.getNodeState(@ptrCast(self)) orelse return null;
const state = page.getNodeState(@alignCast(@ptrCast(self))) orelse return null;
return state.onerror;
}
pub fn set_onerror(self: *parser.Script, function: ?Env.Function, page: *Page) !void {
const state = try page.getOrCreateNodeState(@ptrCast(self));
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
state.onerror = function;
}
};
@@ -969,101 +1200,176 @@ pub const HTMLSourceElement = struct {
pub const Self = parser.Source;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLSpanElement = struct {
pub const Self = parser.Span;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLStyleElement = struct {
pub const Self = parser.Style;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTableElement = struct {
pub const Self = parser.Table;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTableCaptionElement = struct {
pub const Self = parser.TableCaption;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTableCellElement = struct {
pub const Self = parser.TableCell;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTableColElement = struct {
pub const Self = parser.TableCol;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTableRowElement = struct {
pub const Self = parser.TableRow;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTableSectionElement = struct {
pub const Self = parser.TableSection;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTemplateElement = struct {
pub const Self = parser.Template;
pub const prototype = *HTMLElement;
pub const subtype = .node;
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 {
pub const Self = parser.TextArea;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTimeElement = struct {
pub const Self = parser.Time;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTitleElement = struct {
pub const Self = parser.Title;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLTrackElement = struct {
pub const Self = parser.Track;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLUListElement = struct {
pub const Self = parser.UList;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub const HTMLVideoElement = struct {
pub const Self = parser.Video;
pub const prototype = *HTMLElement;
pub const subtype = .node;
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
return constructHtmlElement(page, js_this);
}
};
pub fn toInterface(comptime T: type, e: *parser.Element) !T {
const elem: *align(@alignOf(*parser.Element)) parser.Element = @alignCast(e);
const tag = try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(elem)));
return switch (tag) {
.abbr, .acronym, .address, .article, .aside, .b, .basefont, .bdi, .bdo, .bgsound, .big, .center, .cite, .code, .dd, .details, .dfn, .dt, .em, .figcaption, .figure, .footer, .header, .hgroup, .i, .isindex, .keygen, .kbd, .main, .mark, .marquee, .menu, .menuitem, .nav, .nobr, .noframes, .noscript, .rp, .rt, .ruby, .s, .samp, .section, .small, .spacer, .strike, .strong, .sub, .summary, .sup, .tt, .u, .wbr, ._var => .{ .HTMLElement = @as(*parser.ElementHTML, @ptrCast(elem)) },
.a => .{ .HTMLAnchorElement = @as(*parser.Anchor, @ptrCast(elem)) },
@@ -1135,6 +1441,16 @@ pub fn toInterface(comptime T: type, e: *parser.Element) !T {
};
}
fn constructHtmlElement(page: *Page, js_this: Env.JsThis) !*parser.Element {
const constructor_name = try js_this.constructorName(page.call_arena);
if (!page.window.custom_elements.lookup.contains(constructor_name)) {
return error.IllegalContructor;
}
const el = try parser.documentCreateElement(@ptrCast(page.window.document), constructor_name);
return el;
}
const testing = @import("../../testing.zig");
test "Browser.HTML.Element" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
@@ -1262,6 +1578,8 @@ test "Browser.HTML.Element" {
try runner.testCases(&.{
.{ "let a = document.createElement('a');", null },
.{ "a.href", "" },
.{ "a.host", "" },
.{ "a.href = 'about'", null },
.{ "a.href", "https://lightpanda.io/opensource-browser/about" },
}, .{});
@@ -1272,8 +1590,26 @@ test "Browser.HTML.Element" {
.{ "document.createElement('a').focus()", null },
.{ "document.activeElement === focused", "true" },
}, .{});
try runner.testCases(&.{
.{ "let l2 = document.createElement('link');", null },
.{ "l2.href", "" },
.{ "l2.href = 'https://lightpanda.io/opensource-browser/15'", null },
.{ "l2.href", "https://lightpanda.io/opensource-browser/15" },
.{ "l2.href = '/over/9000'", null },
.{ "l2.href", "https://lightpanda.io/over/9000" },
}, .{});
}
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);
@@ -1357,7 +1693,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>
@@ -1369,14 +1706,27 @@ test "Browser.HTML.HtmlInputElement.propeties.form" {
try runner.testCases(&.{
.{ "let elem_input = document.querySelector('input')", null },
}, .{});
try runner.testCases(&.{.{ "elem_input.form", "[object HTMLFormElement]" }}, .{}); // Initial value
try runner.testCases(&.{
.{ "elem_input.form", "[object HTMLFormElement]" }, // Initial value
.{ "elem_input.form = 'foo'", null },
.{ "elem_input.form", "[object HTMLFormElement]" }, // Invalid
}, .{});
}
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

@@ -56,7 +56,7 @@ pub const HTMLSelectElement = struct {
}
pub fn get_selectedIndex(select: *parser.Select, page: *Page) !i32 {
const state = try page.getOrCreateNodeState(@ptrCast(select));
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(select)));
const selected_index = try parser.selectGetSelectedIndex(select);
// See the explicit_index_set field documentation
@@ -75,7 +75,7 @@ pub const HTMLSelectElement = struct {
// Libdom's dom_html_select_select_set_selected_index will crash if index
// is out of range, and it doesn't properly unset options
pub fn set_selectedIndex(select: *parser.Select, index: i32, page: *Page) !void {
var state = try page.getOrCreateNodeState(@ptrCast(select));
var state = try page.getOrCreateNodeState(@alignCast(@ptrCast(select)));
state.explicit_index_set = true;
const options = try parser.selectGetOptions(select);

View File

@@ -31,8 +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");
@@ -58,6 +61,9 @@ pub const Window = struct {
console: Console = .{},
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("");
@@ -163,6 +169,18 @@ pub const Window = struct {
return &self.performance;
}
pub fn get_customElements(self: *Window) *CustomElementRegistry {
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 });
}
@@ -199,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,
@@ -265,9 +298,31 @@ pub const Window = struct {
behavior: []const u8,
};
};
pub fn _scrollTo(_: *const Window, opts: ScrollToOpts, y: ?u32) void {
pub fn _scrollTo(self: *Window, opts: ScrollToOpts, y: ?u32) !void {
_ = opts;
_ = y;
{
const scroll_event = try parser.eventCreate();
defer parser.eventDestroy(scroll_event);
try parser.eventInit(scroll_event, "scroll", .{});
_ = try parser.eventTargetDispatchEvent(
parser.toEventTarget(Window, self),
scroll_event,
);
}
{
const scroll_end = try parser.eventCreate();
defer parser.eventDestroy(scroll_end);
try parser.eventInit(scroll_end, "scrollend", .{});
_ = try parser.eventTargetDispatchEvent(
parser.toEventTarget(parser.DocumentHTML, self.document),
scroll_end,
);
}
}
};
@@ -337,20 +392,15 @@ test "Browser.HTML.Window" {
// Note however that we in this test do not wait as the request is just send to the browser
try runner.testCases(&.{
.{
\\ let start;
\\ let start = 0;
\\ function step(timestamp) {
\\ if (start === undefined) {
\\ start = timestamp;
\\ }
\\ const elapsed = timestamp - start;
\\ if (elapsed < 2000) {
\\ requestAnimationFrame(step);
\\ }
\\ start = timestamp;
\\ }
,
null,
},
.{ "requestAnimationFrame(step);", null }, // returned id is checked in the next test
.{ " start > 0", "true" },
}, .{});
// cancelAnimationFrame should be able to cancel a request with the given id
@@ -386,4 +436,36 @@ 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" },
}, .{});
try runner.testCases(&.{
.{ "let scroll = false; let scrolend = false", null },
.{ "window.addEventListener('scroll', () => {scroll = true});", null },
.{ "document.addEventListener('scrollend', () => {scrollend = true});", null },
.{ "window.scrollTo(0)", null },
.{ "scroll", "true" },
.{ "scrollend", "true" },
}, .{});
}

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

@@ -25,7 +25,10 @@ const c = @cImport({
@cInclude("events/event_target.h");
@cInclude("events/event.h");
@cInclude("events/mouse_event.h");
@cInclude("events/keyboard_event.h");
@cInclude("utils/validate.h");
@cInclude("html/html_element.h");
@cInclude("html/html_document.h");
});
const mimalloc = @import("mimalloc.zig");
@@ -522,6 +525,9 @@ pub const EventType = enum(u8) {
progress_event = 1,
custom_event = 2,
mouse_event = 3,
error_event = 4,
abort_signal = 5,
xhr_event = 6,
};
pub const MutationEvent = c.dom_mutation_event;
@@ -550,7 +556,7 @@ pub fn mutationEventRelatedNode(evt: *MutationEvent) !?*Node {
const err = c._dom_mutation_event_get_related_node(evt, &n);
try DOMErr(err);
if (n == null) return null;
return @as(*Node, @ptrCast(n));
return @as(*Node, @alignCast(@ptrCast(n)));
}
// EventListener
@@ -565,7 +571,7 @@ fn eventListenerGetData(lst: *EventListener) ?*anyopaque {
pub const EventTarget = c.dom_event_target;
pub fn eventTargetToNode(et: *EventTarget) *Node {
return @as(*Node, @ptrCast(et));
return @as(*Node, @alignCast(@ptrCast(et)));
}
fn eventTargetVtable(et: *EventTarget) c.dom_event_target_vtable {
@@ -772,6 +778,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 {
@@ -862,6 +879,59 @@ pub fn mouseEventDefaultPrevented(evt: *MouseEvent) !bool {
return eventDefaultPrevented(@ptrCast(evt));
}
// KeyboardEvent
pub const KeyboardEvent = c.dom_keyboard_event;
pub fn keyboardEventCreate() !*KeyboardEvent {
var evt: ?*KeyboardEvent = undefined;
const err = c._dom_keyboard_event_create(&evt);
try DOMErr(err);
return evt.?;
}
pub fn keyboardEventDestroy(evt: *KeyboardEvent) void {
c._dom_keyboard_event_destroy(evt);
}
const KeyboardEventOpts = struct {
key: []const u8,
code: []const u8,
bubbles: bool = false,
cancelable: bool = false,
ctrl: bool = false,
alt: bool = false,
shift: bool = false,
meta: bool = false,
};
pub fn keyboardEventInit(evt: *KeyboardEvent, typ: []const u8, opts: KeyboardEventOpts) !void {
const s = try strFromData(typ);
const err = c._dom_keyboard_event_init(
evt,
s,
opts.bubbles,
opts.cancelable,
null, // dom_abstract_view* ?
try strFromData(opts.key),
try strFromData(opts.code),
0, // location 0 == standard
opts.ctrl,
opts.shift,
opts.alt,
opts.meta,
false, // repease
false, // is_composiom
);
try DOMErr(err);
}
pub fn keyboardEventGetKey(evt: *KeyboardEvent) ![]const u8 {
var s: ?*String = undefined;
_ = c._dom_keyboard_event_get_key(evt, &s);
return strToData(s.?);
}
// NodeType
pub const NodeType = enum(u4) {
@@ -894,7 +964,7 @@ pub fn nodeListItem(nodeList: *NodeList, index: u32) !?*Node {
const err = c._dom_nodelist_item(nodeList, index, &n);
try DOMErr(err);
if (n == null) return null;
return @as(*Node, @ptrCast(n));
return @as(*Node, @alignCast(@ptrCast(n)));
}
// NodeExternal is the libdom public representation of a Node.
@@ -1323,7 +1393,7 @@ fn characterDataVtable(data: *CharacterData) c.dom_characterdata_vtable {
}
pub inline fn characterDataToNode(cdata: *CharacterData) *Node {
return @as(*Node, @ptrCast(cdata));
return @as(*Node, @alignCast(@ptrCast(cdata)));
}
pub fn characterDataData(cdata: *CharacterData) ![]const u8 {
@@ -1408,7 +1478,7 @@ pub const ProcessingInstruction = c.dom_processing_instruction;
// processingInstructionToNode is an helper to convert an ProcessingInstruction to a node.
pub inline fn processingInstructionToNode(pi: *ProcessingInstruction) *Node {
return @as(*Node, @ptrCast(pi));
return @as(*Node, @alignCast(@ptrCast(pi)));
}
pub fn processInstructionCopy(pi: *ProcessingInstruction) !*ProcessingInstruction {
@@ -1463,7 +1533,7 @@ pub fn attributeGetOwnerElement(a: *Attribute) !?*Element {
// attributeToNode is an helper to convert an attribute to a node.
pub inline fn attributeToNode(a: *Attribute) *Node {
return @as(*Node, @ptrCast(a));
return @as(*Node, @alignCast(@ptrCast(a)));
}
// Element
@@ -1601,7 +1671,7 @@ pub fn elementHasClass(elem: *Element, class: []const u8) !bool {
// elementToNode is an helper to convert an element to a node.
pub inline fn elementToNode(e: *Element) *Node {
return @as(*Node, @ptrCast(e));
return @as(*Node, @alignCast(@ptrCast(e)));
}
// TokenList
@@ -1685,14 +1755,14 @@ pub fn elementHTMLGetTagType(elem_html: *ElementHTML) !Tag {
// scriptToElt is an helper to convert an script to an element.
pub inline fn scriptToElt(s: *Script) *Element {
return @as(*Element, @ptrCast(s));
return @as(*Element, @alignCast(@ptrCast(s)));
}
// HTMLAnchorElement
// anchorToNode is an helper to convert an anchor to a node.
pub inline fn anchorToNode(a: *Anchor) *Node {
return @as(*Node, @ptrCast(a));
return @as(*Node, @alignCast(@ptrCast(a)));
}
pub fn anchorGetTarget(a: *Anchor) ![]const u8 {
@@ -1760,6 +1830,21 @@ pub fn anchorSetRel(a: *Anchor, rel: []const u8) !void {
try DOMErr(err);
}
// HTMLLinkElement
pub fn linkGetHref(link: *Link) ![]const u8 {
var res: ?*String = undefined;
const err = c.dom_html_link_element_get_href(link, &res);
try DOMErr(err);
if (res == null) return "";
return strToData(res.?);
}
pub fn linkSetHref(link: *Link, href: []const u8) !void {
const err = c.dom_html_link_element_set_href(link, try strFromData(href));
try DOMErr(err);
}
// ElementsHTML
pub const MediaElement = struct { base: *c.dom_html_element };
@@ -1837,7 +1922,7 @@ pub const OptionCollection = c.dom_html_options_collection;
pub const DocumentFragment = c.dom_document_fragment;
pub inline fn documentFragmentToNode(doc: *DocumentFragment) *Node {
return @as(*Node, @ptrCast(doc));
return @as(*Node, @alignCast(@ptrCast(doc)));
}
pub fn documentFragmentBodyChildren(doc: *DocumentFragment) !?*NodeList {
@@ -1947,7 +2032,7 @@ pub inline fn domImplementationCreateHTMLDocument(title: ?[]const u8) !*Document
if (title) |t| {
const htitle = try documentCreateElement(doc, "title");
const txt = try documentCreateTextNode(doc, t);
_ = try nodeAppendChild(elementToNode(htitle), @as(*Node, @ptrCast(txt)));
_ = try nodeAppendChild(elementToNode(htitle), @as(*Node, @alignCast(@ptrCast(txt))));
_ = try nodeAppendChild(elementToNode(head), elementToNode(htitle));
}
@@ -1965,7 +2050,7 @@ fn documentVtable(doc: *Document) c.dom_document_vtable {
}
pub inline fn documentToNode(doc: *Document) *Node {
return @as(*Node, @ptrCast(doc));
return @as(*Node, @alignCast(@ptrCast(doc)));
}
pub inline fn documentGetElementById(doc: *Document, id: []const u8) !?*Element {
@@ -2103,7 +2188,7 @@ pub inline fn documentImportNode(doc: *Document, node: *Node, deep: bool) !*Node
const nodeext = toNodeExternal(Node, node);
const err = documentVtable(doc).dom_document_import_node.?(doc, nodeext, deep, &res);
try DOMErr(err);
return @as(*Node, @ptrCast(res));
return @as(*Node, @alignCast(@ptrCast(res)));
}
pub inline fn documentAdoptNode(doc: *Document, node: *Node) !*Node {
@@ -2111,7 +2196,7 @@ pub inline fn documentAdoptNode(doc: *Document, node: *Node) !*Node {
const nodeext = toNodeExternal(Node, node);
const err = documentVtable(doc).dom_document_adopt_node.?(doc, nodeext, &res);
try DOMErr(err);
return @as(*Node, @ptrCast(res));
return @as(*Node, @alignCast(@ptrCast(res)));
}
pub inline fn documentCreateAttribute(doc: *Document, name: []const u8) !*Attribute {
@@ -2146,7 +2231,7 @@ pub const DocumentHTML = c.dom_html_document;
// documentHTMLToNode is an helper to convert a documentHTML to an node.
pub inline fn documentHTMLToNode(doc: *DocumentHTML) *Node {
return @as(*Node, @ptrCast(doc));
return @as(*Node, @alignCast(@ptrCast(doc)));
}
fn documentHTMLVtable(doc_html: *DocumentHTML) c.dom_html_document_vtable {
@@ -2291,7 +2376,7 @@ pub inline fn documentHTMLBody(doc_html: *DocumentHTML) !?*Body {
}
pub inline fn bodyToElement(body: *Body) *Element {
return @as(*Element, @ptrCast(body));
return @as(*Element, @alignCast(@ptrCast(body)));
}
pub inline fn documentHTMLSetBody(doc_html: *DocumentHTML, elt: ?*ElementHTML) !void {
@@ -2330,7 +2415,7 @@ pub inline fn documentHTMLSetTitle(doc: *DocumentHTML, v: []const u8) !void {
pub fn documentHTMLSetCurrentScript(doc: *DocumentHTML, script: ?*Script) !void {
var s: ?*ElementHTML = null;
if (script != null) s = @ptrCast(script.?);
if (script != null) s = @alignCast(@ptrCast(script.?));
const err = documentHTMLVtable(doc).set_current_script.?(doc, s);
try DOMErr(err);
}
@@ -2391,6 +2476,11 @@ pub fn textareaGetValue(textarea: *TextArea) ![]const u8 {
return strToData(s);
}
pub fn textareaSetValue(textarea: *TextArea, value: []const u8) !void {
const err = c.dom_html_text_area_element_set_value(textarea, try strFromData(value));
try DOMErr(err);
}
// Select
pub fn selectGetOptions(select: *Select) !*OptionCollection {
var collection: ?*OptionCollection = null;
@@ -2759,7 +2849,7 @@ pub fn inputSetType(input: *Input, type_: []const u8) !void {
}
}
const new_type = if (found) type_ else "text";
try elementSetAttribute(@ptrCast(input), "type", new_type);
try elementSetAttribute(@alignCast(@ptrCast(input)), "type", new_type);
}
pub fn inputGetValue(input: *Input) ![]const u8 {
@@ -2773,3 +2863,11 @@ pub fn inputSetValue(input: *Input, value: []const u8) !void {
const err = c.dom_html_input_element_set_value(input, try strFromData(value));
try DOMErr(err);
}
pub fn buttonGetType(button: *Button) ![]const u8 {
var s_: ?*String = null;
const err = c.dom_html_button_element_get_type(button, &s_);
try DOMErr(err);
const s = s_ orelse return "button";
return strToData(s);
}

View File

@@ -78,20 +78,17 @@ 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,
// Our JavaScript context for this specific page. This is what we use to
// execute any JavaScript
scope: *Env.Scope,
// 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,
main_context: *Env.JsContext,
// indicates intention to navigate to another page on the next loop execution.
delayed_navigation: bool = false,
@@ -112,19 +109,24 @@ 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,
}),
.scope = undefined,
.module_map = .empty,
.main_context = undefined,
};
self.scope = try session.executor.startScope(&self.window, self, self, true);
self.main_context = try session.executor.createJsContext(&self.window, self, self, true);
// load polyfills
try polyfill.load(self.arena, self.scope);
try polyfill.load(self.arena, 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.microtask_node);
_ = try session.browser.app.loop.timeout(100 * std.time.ns_per_ms, &self.messageloop_node);
}
}
fn microtaskCallback(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
@@ -133,6 +135,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| {
@@ -145,29 +153,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.scope);
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", .{});
@@ -190,7 +186,12 @@ pub const Page = struct {
const session = self.session;
const notification = session.browser.notification;
log.debug(.http, "navigate", .{ .url = request_url, .reason = opts.reason });
log.debug(.http, "navigate", .{
.url = request_url,
.method = opts.method,
.reason = opts.reason,
.body = opts.body != null,
});
// if the url is about:blank, nothing to do.
if (std.mem.eql(u8, "about:blank", request_url.raw)) {
@@ -210,7 +211,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;
@@ -247,19 +248,37 @@ pub const Page = struct {
.content_type = content_type,
.charset = mime.charset,
.url = request_url,
.method = opts.method,
.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();
@@ -305,6 +324,12 @@ pub const Page = struct {
&self.window_clicked_event_node,
false,
);
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Element, document_element),
"keydown",
&self.keydown_event_node,
false,
);
// https://html.spec.whatwg.org/#read-html
@@ -337,8 +362,23 @@ pub const Page = struct {
continue;
}
const e = parser.nodeToElement(next.?);
const current = next.?;
const e = parser.nodeToElement(current);
const tag = try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(e)));
// if (tag == .undef) {
// const tag_name = try parser.nodeLocalName(@ptrCast(e));
// const custom_elements = &self.window.custom_elements;
// if (custom_elements._get(tag_name)) |construct| {
// try construct.printFunc();
// // This is just here for testing for now.
// // var result: Env.Function.Result = undefined;
// // _ = try construct.newInstance(*parser.Element, &result);
// log.info(.browser, "Registered WebComponent Found", .{ .element_name = tag_name });
// }
// }
if (tag != .script) {
// ignore non-js script.
continue;
@@ -373,11 +413,15 @@ pub const Page = struct {
// > immediately before the browser continues to parse the
// > page.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#notes
self.evalScript(&script);
if (self.evalScript(&script) == false) {
return;
}
}
for (defer_scripts.items) |*script| {
self.evalScript(script);
if (self.evalScript(script) == false) {
return;
}
}
// dispatch DOMContentLoaded before the transition to "complete",
// at the point where all subresources apart from async script elements
@@ -387,7 +431,9 @@ pub const Page = struct {
// eval async scripts.
for (async_scripts.items) |*script| {
self.evalScript(script);
if (self.evalScript(script) == false) {
return;
}
}
try HTMLDocument.documentIsComplete(html_doc, self);
@@ -404,10 +450,13 @@ pub const Page = struct {
);
}
fn evalScript(self: *Page, script: *const Script) void {
self.tryEvalScript(script) catch |err| {
log.err(.js, "eval script error", .{ .err = err, .src = script.src });
fn evalScript(self: *Page, script: *const Script) bool {
self.tryEvalScript(script) catch |err| switch (err) {
error.JsErr => {}, // already been logged with detail
error.Terminated => return false,
else => log.err(.js, "eval script error", .{ .err = err, .src = script.src }),
};
return true;
}
// evalScript evaluates the src in priority.
@@ -424,26 +473,17 @@ pub const Page = struct {
const src = script.src orelse {
// source is inline
// TODO handle charset attribute
if (try parser.nodeTextContent(parser.elementToNode(script.element))) |text| {
try script.eval(self, text);
}
return;
const script_source = try parser.nodeTextContent(parser.elementToNode(script.element)) orelse return;
return script.eval(self, script_source);
};
self.current_script = script;
defer self.current_script = null;
// https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-classic-script
const body = (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;
};
script.eval(self, body) catch |err| switch (err) {
error.JsErr => {}, // nothing to do here.
else => return err,
};
return script.eval(self, script_source);
// TODO If el's from an external file is true, then fire an event
// named load at el.
@@ -453,7 +493,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.
@@ -461,22 +505,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();
@@ -484,7 +533,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;
}
@@ -502,7 +552,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;
@@ -572,14 +623,14 @@ pub const Page = struct {
},
.input => {
const element: *parser.Element = @ptrCast(node);
const input_type = (try parser.elementGetAttribute(element, "type")) orelse return;
const input_type = try parser.inputGetType(@ptrCast(element));
if (std.ascii.eqlIgnoreCase(input_type, "submit")) {
return self.elementSubmitForm(element);
}
},
.button => {
const element: *parser.Element = @ptrCast(node);
const button_type = (try parser.elementGetAttribute(element, "type")) orelse return;
const button_type = try parser.buttonGetType(@ptrCast(element));
if (std.ascii.eqlIgnoreCase(button_type, "submit")) {
return self.elementSubmitForm(element);
}
@@ -593,18 +644,111 @@ pub const Page = struct {
}
}
pub const KeyboardEvent = struct {
type: Type,
key: []const u8,
code: []const u8,
alt: bool,
ctrl: bool,
meta: bool,
shift: bool,
const Type = enum {
keydown,
};
};
pub fn keyboardEvent(self: *Page, kbe: KeyboardEvent) !void {
if (kbe.type != .keydown) {
return;
}
const Document = @import("dom/document.zig").Document;
const element = (try Document.getActiveElement(@ptrCast(self.window.document), self)) orelse return;
const event = try parser.keyboardEventCreate();
defer parser.keyboardEventDestroy(event);
try parser.keyboardEventInit(event, "keydown", .{
.bubbles = true,
.cancelable = true,
.key = kbe.key,
.code = kbe.code,
.alt = kbe.alt,
.ctrl = kbe.ctrl,
.meta = kbe.meta,
.shift = kbe.shift,
});
_ = try parser.elementDispatchEvent(element, @ptrCast(event));
}
fn keydownCallback(node: *parser.EventNode, event: *parser.Event) void {
const self: *Page = @fieldParentPtr("keydown_event_node", node);
self._keydownCallback(event) catch |err| {
log.err(.browser, "keydown handler error", .{ .err = err });
};
}
fn _keydownCallback(self: *Page, event: *parser.Event) !void {
const target = (try parser.eventTarget(event)) orelse return;
const node = parser.eventTargetToNode(target);
const tag = (try parser.nodeHTMLGetTagType(node)) orelse return;
const kbe: *parser.KeyboardEvent = @ptrCast(event);
var new_key = try parser.keyboardEventGetKey(kbe);
if (std.mem.eql(u8, new_key, "Dead")) {
return;
}
switch (tag) {
.input => {
const element: *parser.Element = @ptrCast(node);
const input_type = try parser.inputGetType(@ptrCast(element));
if (std.mem.eql(u8, input_type, "text")) {
if (std.mem.eql(u8, new_key, "Enter")) {
const form = (try self.formForElement(element)) orelse return;
return self.submitForm(@ptrCast(form), null);
}
const value = try parser.inputGetValue(@ptrCast(element));
const new_value = try std.mem.concat(self.arena, u8, &.{ value, new_key });
try parser.inputSetValue(@ptrCast(element), new_value);
}
},
.textarea => {
const value = try parser.textareaGetValue(@ptrCast(node));
if (std.mem.eql(u8, new_key, "Enter")) {
new_key = "\n";
}
const new_value = try std.mem.concat(self.arena, u8, &.{ value, new_key });
try parser.textareaSetValue(@ptrCast(node), new_value);
},
else => {},
}
}
// We cannot navigate immediately as navigating will delete the DOM tree,
// which holds this event's node.
// As such we schedule the function to be called as soon as possible.
// The page.arena is safe to use here, but the transfer_arena exists
// specifically for this type of lifetime.
pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void {
log.debug(.browser, "delayed navigation", .{
.url = url,
.reason = opts.reason,
});
self.delayed_navigation = true;
const arena = self.session.transfer_arena;
const session = self.session;
const arena = session.transfer_arena;
const navi = try arena.create(DelayedNavigation);
navi.* = .{
.opts = opts,
.session = self.session,
.url = try arena.dupe(u8, url),
.session = session,
.url = try self.url.resolve(arena, url),
};
// In v8, this throws an exception which JS code cannot catch.
session.executor.terminateExecution();
_ = try self.loop.timeout(0, &navi.navigate_node);
}
@@ -633,13 +777,13 @@ pub const Page = struct {
const transfer_arena = self.session.transfer_arena;
var form_data = try FormData.fromForm(form, submitter, self);
const encoding = try parser.elementGetAttribute(@ptrCast(form), "enctype");
const encoding = try parser.elementGetAttribute(@alignCast(@ptrCast(form)), "enctype");
var buf: std.ArrayListUnmanaged(u8) = .empty;
try form_data.write(encoding, buf.writer(transfer_arena));
const method = try parser.elementGetAttribute(@ptrCast(form), "method") orelse "";
var action = try parser.elementGetAttribute(@ptrCast(form), "action") orelse self.url.raw;
const method = try parser.elementGetAttribute(@alignCast(@ptrCast(form)), "method") orelse "";
var action = try parser.elementGetAttribute(@alignCast(@ptrCast(form)), "action") orelse self.url.raw;
var opts = NavigateOpts{
.reason = .form,
@@ -650,7 +794,6 @@ pub const Page = struct {
} else {
action = try URL.concatQueryString(transfer_arena, action, buf.items);
}
try self.navigateFromWebAPI(action, opts);
}
@@ -685,22 +828,89 @@ pub const Page = struct {
pub fn stackTrace(self: *Page) !?[]const u8 {
if (comptime builtin.mode == .Debug) {
return self.scope.stackTrace();
return self.main_context.stackTrace();
}
return null;
}
};
const DelayedNavigation = struct {
url: []const u8,
url: URL,
session: *Session,
opts: NavigateOpts,
initial: bool = true,
navigate_node: Loop.CallbackNode = .{ .func = delayNavigate },
// Navigation is blocking, which is problem because it can seize up
// the loop and deadlock. We can only safely try to navigate to a
// new page when we're sure there's at least 1 free slot in the
// http client. We handle this in two phases:
//
// In the first phase, when self.initial == true, we'll shutdown the page
// and create a new one. The shutdown is important, because it resets the
// loop ctx_id and removes the JsContext. Removing the context calls our XHR
// destructors which aborts requests. This is necessary to make sure our
// [blocking] navigate won't block.
//
// In the 2nd phase, we wait until there's a free http slot so that our
// navigate definetly won't block (which could deadlock the system if there
// are still pending async requests, which we've seen happen, even after
// an abort).
fn delayNavigate(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
_ = repeat_delay;
const self: *DelayedNavigation = @fieldParentPtr("navigate_node", node);
self.session.pageNavigate(self.url, self.opts) catch |err| {
const session = self.session;
const initial = self.initial;
if (initial) {
// Prior to schedule this task, we terminated excution to stop
// the running script. If we don't resume it before doing a shutdown
// we'll get an error.
session.executor.resumeExecution();
session.removePage();
_ = session.createPage() catch |err| {
log.err(.browser, "delayed navigation page error", .{
.err = err,
.url = self.url,
});
return;
};
self.initial = false;
}
if (session.browser.http_client.freeSlotCount() == 0) {
log.debug(.browser, "delayed navigate waiting", .{});
const delay = 0 * std.time.ns_per_ms;
// If this isn't the initial check, we can safely re-use the timer
// to check again.
if (initial == false) {
repeat_delay.* = delay;
return;
}
// However, if this _is_ the initial check, we called
// session.removePage above, and that reset the loop ctx_id.
// We can't re-use this timer, because it has the previous ctx_id.
// We can create a new timeout though, and that'll get the new ctx_id.
//
// Page has to be not-null here because we called createPage above.
_ = session.page.?.loop.timeout(delay, &self.navigate_node) catch |err| {
log.err(.browser, "delayed navigation loop err", .{ .err = err });
};
return;
}
const page = session.currentPage() orelse return;
defer if (!page.delayed_navigation) {
// If, while loading the page, we intend to navigate to another
// page, then we need to keep the transfer_arena around, as this
// sub-navigation is probably using it.
_ = session.browser.transfer_arena.reset(.{ .retain_with_limit = 64 * 1024 });
};
return page.navigate(self.url, self.opts) catch |err| {
log.err(.browser, "delayed navigation error", .{ .err = err, .url = self.url });
};
}
@@ -802,28 +1012,42 @@ const Script = struct {
fn eval(self: *const Script, page: *Page, body: []const u8) !void {
var try_catch: Env.TryCatch = undefined;
try_catch.init(page.scope);
try_catch.init(page.main_context);
defer try_catch.deinit();
const src = self.src orelse "inline";
_ = switch (self.kind) {
.javascript => page.scope.exec(body, src),
.module => blk: {
switch (try page.scope.module(body, src)) {
.value => |v| break :blk v,
.exception => |e| {
log.warn(.user_script, "eval module", .{
.src = src,
.err = try e.exception(page.arena),
});
return error.JsErr;
},
}
},
} catch {
if (try try_catch.err(page.arena)) |msg| {
log.warn(.user_script, "eval script", .{ .src = src, .err = msg });
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,
.kind = self.kind,
.cacheable = cacheable,
});
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;
}
if (try try_catch.err(page.arena)) |msg| {
log.warn(.user_script, "eval script", .{
.src = src,
.err = msg,
.cacheable = cacheable,
});
}
try self.executeCallback("onerror", page);
return error.JsErr;
};
@@ -835,9 +1059,9 @@ const Script = struct {
switch (callback) {
.string => |str| {
var try_catch: Env.TryCatch = undefined;
try_catch.init(page.scope);
try_catch.init(page.main_context);
defer try_catch.deinit();
_ = page.scope.exec(str, typ) catch {
_ = page.main_context.exec(str, typ) catch {
if (try try_catch.err(page.arena)) |msg| {
log.warn(.user_script, "script callback", .{
.src = self.src,
@@ -896,10 +1120,15 @@ fn timestamp() u32 {
// immediately.
pub export fn scriptAddedCallback(ctx: ?*anyopaque, element: ?*parser.Element) callconv(.C) void {
const self: *Page = @alignCast(@ptrCast(ctx.?));
if (self.delayed_navigation) {
// if we're planning on navigating to another page, don't run this script
return;
}
var script = Script.init(element.?, self) catch |err| {
log.warn(.browser, "script added init error", .{ .err = err });
return;
} orelse return;
self.evalScript(&script);
_ = self.evalScript(&script);
}

View File

@@ -16,7 +16,7 @@ test "Browser.fetch" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try @import("polyfill.zig").load(testing.allocator, runner.page.scope);
try @import("polyfill.zig").load(testing.allocator, runner.page.main_context);
try runner.testCases(&.{
.{

View File

@@ -30,13 +30,13 @@ const modules = [_]struct {
.{ .name = "polyfill-fetch", .source = @import("fetch.zig").source },
};
pub fn load(allocator: Allocator, scope: *Env.Scope) !void {
pub fn load(allocator: Allocator, js_context: *Env.JsContext) !void {
var try_catch: Env.TryCatch = undefined;
try_catch.init(scope);
try_catch.init(js_context);
defer try_catch.deinit();
for (modules) |m| {
_ = scope.exec(m.source, m.name) catch |err| {
_ = js_context.exec(m.source, m.name) catch |err| {
if (try try_catch.err(allocator)) |msg| {
defer allocator.free(msg);
log.fatal(.app, "polyfill error", .{ .name = m.name, .err = msg });

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

@@ -22,6 +22,7 @@ const Allocator = std.mem.Allocator;
const Env = @import("env.zig").Env;
const Page = @import("page.zig").Page;
const URL = @import("../url.zig").URL;
const Browser = @import("browser.zig").Browser;
const NavigateOpts = @import("page.zig").NavigateOpts;
@@ -72,7 +73,7 @@ pub const Session = struct {
pub fn deinit(self: *Session) void {
if (self.page != null) {
self.removePage() catch {};
self.removePage();
}
self.cookie_jar.deinit();
self.storage_shed.deinit();
@@ -104,7 +105,7 @@ pub const Session = struct {
return page;
}
pub fn removePage(self: *Session) !void {
pub fn removePage(self: *Session) void {
// Inform CDP the page is going to be removed, allowing other worlds to remove themselves before the main one
self.browser.notification.dispatch(.page_remove, .{});
@@ -115,11 +116,11 @@ pub const Session = struct {
// phase. It's important that we clean these up, as they're holding onto
// limited resources (like our fixed-sized http state pool).
//
// First thing we do, is endScope() which will execute the destructor
// First thing we do, is removeJsContext() which will execute the destructor
// of any type that registered a destructor (e.g. XMLHttpRequest).
// This will shutdown any pending sockets, which begins our cleaning
// processed
self.executor.endScope();
self.executor.removeJsContext();
// Second thing we do is reset the loop. This increments the loop ctx_id
// so that any "stale" timeouts we process will get ignored. We need to
@@ -127,12 +128,6 @@ pub const Session = struct {
// window.setTimeout and running microtasks should be ignored
self.browser.app.loop.reset();
// Finally, we run the loop. Because of the reset just above, this will
// ignore any timeouts. And, because of the endScope about this, it
// should ensure that the http requests detect the shutdown socket and
// release their resources.
try self.browser.app.loop.run();
self.page = null;
// clear netsurf memory arena.
@@ -144,28 +139,4 @@ pub const Session = struct {
pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null);
}
pub fn pageNavigate(self: *Session, url_string: []const u8, opts: NavigateOpts) !void {
// currently, this is only called from the page, so let's hope
// it isn't null!
std.debug.assert(self.page != null);
defer if (self.page) |*p| {
if (!p.delayed_navigation) {
// If, while loading the page, we intend to navigate to another
// page, then we need to keep the transfer_arena around, as this
// sub-navigation is probably using it.
_ = self.browser.transfer_arena.reset(.{ .retain_with_limit = 64 * 1024 });
}
};
// it's safe to use the transfer arena here, because the page will
// eventually clone the URL using its own page_arena (after it gets
// the final URL, possibly following redirects)
const url = try self.page.?.url.resolve(self.transfer_arena, url_string);
try self.removePage();
var page = try self.createPage();
return page.navigate(url, opts);
}
};

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

@@ -48,12 +48,17 @@ pub const Interfaces = .{
// allocatorate data, I should be able to retrieve the scheme + the following `:`
// from rawuri.
//
// 2. The other way would bu to copy the `std.Uri` code to ahve a dedicated
// 2. The other way would be to copy the `std.Uri` code to have a dedicated
// parser including the characters we want for the web API.
pub const URL = struct {
uri: std.Uri,
search_params: URLSearchParams,
pub const empty = URL{
.uri = .{ .scheme = "" },
.search_params = .{},
};
const URLArg = union(enum) {
url: *URL,
element: *parser.ElementHTML,
@@ -224,6 +229,19 @@ pub const URL = struct {
pub fn _toJSON(self: *URL, page: *Page) ![]const u8 {
return self.get_href(page);
}
pub fn set_pathname(self: *URL, fragment: []const u8, page: *Page) !void {
// pathname must always start with a '/';
const real_path = blk: {
if (std.mem.startsWith(u8, fragment, "/")) {
break :blk try page.arena.dupe(u8, fragment);
} else {
break :blk try std.fmt.allocPrint(page.arena, "/{s}", .{fragment});
}
};
self.uri.path = .{ .percent_encoded = real_path };
}
};
// uriComponentNullStr converts an optional std.Uri.Component to string value.

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 log = @import("../../log.zig");
const v8 = @import("v8");
const Env = @import("../env.zig").Env;
const Page = @import("../page.zig").Page;
const Element = @import("../dom/element.zig").Element;
pub const CustomElementRegistry = struct {
// tag_name -> Function
lookup: std.StringHashMapUnmanaged(Env.Function) = .empty,
pub fn _define(self: *CustomElementRegistry, tag_name: []const u8, fun: Env.Function, page: *Page) !void {
log.info(.browser, "define custom element", .{ .name = tag_name });
const arena = page.arena;
const gop = try self.lookup.getOrPut(arena, tag_name);
if (!gop.found_existing) {
errdefer _ = self.lookup.remove(tag_name);
const owned_tag_name = try arena.dupe(u8, tag_name);
gop.key_ptr.* = owned_tag_name;
}
gop.value_ptr.* = fun;
fun.setName(tag_name);
}
pub fn _get(self: *CustomElementRegistry, name: []const u8) ?Env.Function {
return self.lookup.get(name);
}
};
const testing = @import("../../testing.zig");
test "Browser.CustomElementRegistry" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
// Basic registry access
.{ "typeof customElements", "object" },
.{ "customElements instanceof CustomElementRegistry", "true" },
// Define a simple custom element
.{
\\ class MyElement extends HTMLElement {
\\ constructor() {
\\ super();
\\ this.textContent = 'Hello World';
\\ }
\\ }
,
null,
},
.{ "customElements.define('my-element', MyElement)", "undefined" },
// Check if element is defined
.{ "customElements.get('my-element') === MyElement", "true" },
// .{ "customElements.get('non-existent')", "null" },
// Create element via document.createElement
.{ "let el = document.createElement('my-element')", "undefined" },
.{ "el instanceof MyElement", "true" },
.{ "el instanceof HTMLElement", "true" },
.{ "el.tagName", "MY-ELEMENT" },
.{ "el.textContent", "Hello World" },
// Create element via HTML parsing
// .{ "document.body.innerHTML = '<my-element></my-element>'", "undefined" },
// .{ "let parsed = document.querySelector('my-element')", "undefined" },
// .{ "parsed instanceof MyElement", "true" },
// .{ "parsed.textContent", "Hello World" },
}, .{});
}

View File

@@ -0,0 +1,23 @@
// 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 CustomElementRegistry = @import("custom_element_registry.zig").CustomElementRegistry;
pub const Interfaces = .{
CustomElementRegistry,
};

View File

@@ -39,6 +39,7 @@ pub const XMLHttpRequestEventTarget = struct {
onload_cbk: ?Function = null,
ontimeout_cbk: ?Function = null,
onloadend_cbk: ?Function = null,
onreadystatechange_cbk: ?Function = null,
fn register(
self: *XMLHttpRequestEventTarget,
@@ -86,6 +87,9 @@ pub const XMLHttpRequestEventTarget = struct {
pub fn get_onloadend(self: *XMLHttpRequestEventTarget) ?Function {
return self.onloadend_cbk;
}
pub fn get_onreadystatechange(self: *XMLHttpRequestEventTarget) ?Function {
return self.onreadystatechange_cbk;
}
pub fn set_onloadstart(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
if (self.onloadstart_cbk) |cbk| try self.unregister("loadstart", cbk.id);
@@ -111,4 +115,8 @@ pub const XMLHttpRequestEventTarget = struct {
if (self.onloadend_cbk) |cbk| try self.unregister("loadend", cbk.id);
self.onloadend_cbk = try self.register(page.arena, "loadend", listener);
}
pub fn set_onreadystatechange(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
if (self.onreadystatechange_cbk) |cbk| try self.unregister("readystatechange", cbk.id);
self.onreadystatechange_cbk = try self.register(page.arena, "readystatechange", listener);
}
};

View File

@@ -115,17 +115,24 @@ const EntryIterable = iterator.Iterable(kv.EntryIterator, "FormDataEntryIterator
// TODO: handle disabled fieldsets
fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !kv.List {
const arena = page.arena;
const collection = try parser.formGetCollection(form);
const len = try parser.htmlCollectionGetLength(collection);
// Don't use libdom's formGetCollection (aka dom_html_form_element_get_elements)
// It doesn't work with dynamically added elements, because their form
// property doesn't get set. We should fix that.
// However, even once fixed, there are other form-collection features we
// probably want to implement (like disabled fieldsets), so we might want
// to stick with our own walker even if fix libdom to properly support
// dynamically added elements.
const node_list = try @import("../dom/css.zig").querySelectorAll(arena, @alignCast(@ptrCast(form)), "input,select,button,textarea");
const nodes = node_list.nodes.items;
var entries: kv.List = .{};
try entries.ensureTotalCapacity(arena, len);
try entries.ensureTotalCapacity(arena, nodes.len);
var submitter_included = false;
const submitter_name_ = try getSubmitterName(submitter_);
for (0..len) |i| {
const node = try parser.htmlCollectionItem(collection, @intCast(i));
for (nodes) |node| {
const element = parser.nodeToElement(node);
// must have a name
@@ -137,7 +144,7 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page
const tag = try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(element)));
switch (tag) {
.input => {
const tpe = try parser.elementGetAttribute(element, "type") orelse "";
const tpe = try parser.inputGetType(@ptrCast(element));
if (std.ascii.eqlIgnoreCase(tpe, "image")) {
if (submitter_name_) |submitter_name| {
if (std.mem.eql(u8, submitter_name, name)) {
@@ -162,7 +169,7 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page
}
submitter_included = true;
}
const value = (try parser.elementGetAttribute(element, "value")) orelse "";
const value = try parser.inputGetValue(@ptrCast(element));
try entries.appendOwned(arena, name, value);
},
.select => {
@@ -181,19 +188,16 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page
submitter_included = true;
}
},
else => {
log.warn(.web_api, "unsupported form element", .{ .tag = @tagName(tag) });
continue;
},
else => unreachable,
}
}
if (submitter_included == false) {
if (submitter_) |submitter| {
if (submitter_name_) |submitter_name| {
// this can happen if the submitter is outside the form, but associated
// with the form via a form=ID attribute
const value = (try parser.elementGetAttribute(@ptrCast(submitter), "value")) orelse "";
try entries.appendOwned(arena, submitter_name_.?, value);
const value = (try parser.elementGetAttribute(@ptrCast(submitter_.?), "value")) orelse "";
try entries.appendOwned(arena, submitter_name, value);
}
}
@@ -216,7 +220,7 @@ fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u
if (is_multiple == false) {
const option = try parser.optionCollectionItem(options, @intCast(selected_index));
if (try parser.elementGetAttribute(@ptrCast(option), "disabled") != null) {
if (try parser.elementGetAttribute(@alignCast(@ptrCast(option)), "disabled") != null) {
return;
}
const value = try parser.optionGetValue(option);
@@ -228,7 +232,7 @@ fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u
// we can go directly to the first one
for (@intCast(selected_index)..len) |i| {
const option = try parser.optionCollectionItem(options, @intCast(i));
if (try parser.elementGetAttribute(@ptrCast(option), "disabled") != null) {
if (try parser.elementGetAttribute(@alignCast(@ptrCast(option)), "disabled") != null) {
continue;
}
@@ -249,7 +253,7 @@ fn getSubmitterName(submitter_: ?*parser.ElementHTML) !?[]const u8 {
switch (tag) {
.button => return name,
.input => {
const tpe = (try parser.elementGetAttribute(element, "type")) orelse "";
const tpe = try parser.inputGetType(@ptrCast(element));
// only an image type can be a sumbitter
if (std.ascii.eqlIgnoreCase(tpe, "image") or std.ascii.eqlIgnoreCase(tpe, "submit")) {
return name;
@@ -297,6 +301,7 @@ test "Browser.FormData" {
\\ <input type=submit name=s2 value=s2-v>
\\ <input type=image name=i1 value=i1-v>
\\ </form>
\\ <input type=text name=abc value=123 form=form1>
});
defer runner.deinit();
@@ -356,6 +361,8 @@ test "Browser.FormData" {
try runner.testCases(&.{
.{ "let form1 = document.getElementById('form1')", null },
.{ "let input = document.createElement('input');", null },
.{ "input.name = 'dyn'; input.value= 'dyn-v'; form1.appendChild(input);", null },
.{ "let submit1 = document.getElementById('s1')", null },
.{ "let f2 = new FormData(form1, submit1)", null },
.{ "acc = '';", null },
@@ -378,6 +385,7 @@ test "Browser.FormData" {
\\mlt-2=water
\\mlt-2=tea
\\s1=s1-v
\\dyn=dyn-v
},
}, .{});
}

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

@@ -138,6 +138,13 @@ pub const XMLHttpRequest = struct {
done = 4,
};
// class attributes
pub const _UNSENT = @intFromEnum(State.unsent);
pub const _OPENED = @intFromEnum(State.opened);
pub const _HEADERS_RECEIVED = @intFromEnum(State.headers_received);
pub const _LOADING = @intFromEnum(State.loading);
pub const _DONE = @intFromEnum(State.done);
// https://xhr.spec.whatwg.org/#response-type
const ResponseType = enum {
Empty,
@@ -338,9 +345,20 @@ pub const XMLHttpRequest = struct {
// dispatch request event.
// errors are logged only.
fn dispatchEvt(self: *XMLHttpRequest, typ: []const u8) void {
log.debug(.script_event, "dispatch event", .{ .type = typ, .source = "xhr" });
log.debug(.script_event, "dispatch event", .{
.type = typ,
.source = "xhr",
.method = self.method,
.url = self.url,
});
self._dispatchEvt(typ) catch |err| {
log.err(.app, "dispatch event error", .{ .err = err, .type = typ, .source = "xhr" });
log.err(.app, "dispatch event error", .{
.err = err,
.type = typ,
.source = "xhr",
.method = self.method,
.url = self.url,
});
};
}
@@ -349,6 +367,8 @@ pub const XMLHttpRequest = struct {
// We can we defer event destroy once the event is dispatched.
defer parser.eventDestroy(evt);
try parser.eventSetInternalType(evt, .xhr_event);
try parser.eventInit(evt, typ, .{ .bubbles = true, .cancelable = true });
_ = try parser.eventTargetDispatchEvent(@as(*parser.EventTarget, @ptrCast(self)), evt);
}
@@ -358,9 +378,20 @@ pub const XMLHttpRequest = struct {
typ: []const u8,
opts: ProgressEvent.EventInit,
) void {
log.debug(.script_event, "dispatch progress event", .{ .type = typ, .source = "xhr" });
log.debug(.script_event, "dispatch progress event", .{
.type = typ,
.source = "xhr",
.method = self.method,
.url = self.url,
});
self._dispatchProgressEvent(typ, opts) catch |err| {
log.err(.app, "dispatch progress event error", .{ .err = err, .type = typ, .source = "xhr" });
log.err(.app, "dispatch progress event error", .{
.err = err,
.type = typ,
.source = "xhr",
.method = self.method,
.url = self.url,
});
};
}
@@ -436,7 +467,6 @@ pub const XMLHttpRequest = struct {
&self.url.?.uri,
self,
onHttpRequestReady,
self.loop,
);
}
@@ -453,6 +483,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) {
@@ -471,7 +502,7 @@ pub const XMLHttpRequest = struct {
}
}
try request.sendAsync(self.loop, self, .{});
try request.sendAsync(self, .{});
self.request = request;
}
@@ -557,11 +588,27 @@ pub const XMLHttpRequest = struct {
}
fn onErr(self: *XMLHttpRequest, err: anyerror) void {
self.state = .done;
self.send_flag = false;
self.dispatchEvt("readystatechange");
self.dispatchProgressEvent("error", .{});
self.dispatchProgressEvent("loadend", .{});
// capture the state before we change it
const s = self.state;
const is_abort = err == DOMError.Abort;
if (is_abort) {
self.state = .unsent;
} else {
self.state = .done;
self.dispatchEvt("error");
}
if (s != .done or s != .unsent) {
self.dispatchEvt("readystatechange");
if (is_abort) {
self.dispatchProgressEvent("abort", .{});
}
self.dispatchProgressEvent("loadend", .{});
}
const level: log.Level = if (err == DOMError.Abort) .debug else .err;
log.log(.http, level, "error", .{
@@ -900,4 +947,26 @@ test "Browser.XHR.XMLHttpRequest" {
// So the url has been retrieved.
.{ "status", "200" },
}, .{});
try runner.testCases(&.{
.{ "const req6 = new XMLHttpRequest()", null },
.{
\\ var readyStates = [];
\\ var currentTarget = null;
\\ req6.onreadystatechange = (e) => {
\\ currentTarget = e.currentTarget;
\\ readyStates.push(req6.readyState);
\\ }
,
null,
},
.{ "req6.open('GET', 'https://127.0.0.1:9581/xhr')", null },
.{ "req6.send()", null },
.{ "readyStates.length", "4" },
.{ "readyStates[0] === XMLHttpRequest.OPENED", "true" },
.{ "readyStates[1] === XMLHttpRequest.HEADERS_RECEIVED", "true" },
.{ "readyStates[2] === XMLHttpRequest.LOADING", "true" },
.{ "readyStates[3] === XMLHttpRequest.DONE", "true" },
.{ "currentTarget == req6", "true" },
}, .{});
}

View File

@@ -1,52 +0,0 @@
// 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 jsonToCbor = @import("json_to_cbor.zig").jsonToCbor;
pub const cborToJson = @import("cbor_to_json.zig").cborToJson;
const testing = @import("../../testing.zig");
test "cbor" {
try testCbor("{\"x\":null}");
try testCbor("{\"x\":true}");
try testCbor("{\"x\":false}");
try testCbor("{\"x\":0}");
try testCbor("{\"x\":1}");
try testCbor("{\"x\":-1}");
try testCbor("{\"x\":4832839283}");
try testCbor("{\"x\":-998128383}");
try testCbor("{\"x\":48328.39283}");
try testCbor("{\"x\":-9981.28383}");
try testCbor("{\"x\":\"\"}");
try testCbor("{\"x\":\"over 9000!\"}");
try testCbor("{\"x\":[]}");
try testCbor("{\"x\":{}}");
}
fn testCbor(json: []const u8) !void {
const std = @import("std");
defer testing.reset();
const encoded = try jsonToCbor(testing.arena_allocator, json);
var arr: std.ArrayListUnmanaged(u8) = .empty;
try cborToJson(encoded, arr.writer(testing.arena_allocator));
try testing.expectEqual(json, arr.items);
}

View File

@@ -1,252 +0,0 @@
// 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 Allocator = std.mem.Allocator;
const Error = error{
EOSReadingFloat,
UnknownTag,
EOSReadingArray,
UnterminatedArray,
EOSReadingMap,
UnterminatedMap,
EOSReadingLength,
InvalidLength,
MissingData,
EOSExpectedString,
ExpectedString,
OutOfMemory,
EmbeddedDataIsShort,
InvalidEmbeddedDataEnvelope,
};
pub fn cborToJson(input: []const u8, writer: anytype) !void {
if (input.len < 7) {
return error.InvalidCBORMessage;
}
var data = input;
while (data.len > 0) {
data = try writeValue(data, writer);
}
}
fn writeValue(data: []const u8, writer: anytype) Error![]const u8 {
switch (data[0]) {
0xf4 => {
try writer.writeAll("false");
return data[1..];
},
0xf5 => {
try writer.writeAll("true");
return data[1..];
},
0xf6, 0xf7 => { // 0xf7 is undefined
try writer.writeAll("null");
return data[1..];
},
0x9f => return writeInfiniteArray(data[1..], writer),
0xbf => return writeInfiniteMap(data[1..], writer),
0xd8 => {
// This is major type 6, which is generic tagged data. We only
// support 1 tag: embedded cbor data.
if (data.len < 7) {
return error.EmbeddedDataIsShort;
}
if (data[1] != 0x18 or data[2] != 0x5a) {
return error.InvalidEmbeddedDataEnvelope;
}
// skip the length, we have the full paylaod
return writeValue(data[7..], writer);
},
0xf9 => { // f16
if (data.len < 3) {
return error.EOSReadingFloat;
}
try writer.print("{d}", .{@as(f16, @bitCast(std.mem.readInt(u16, data[1..3], .big)))});
return data[3..];
},
0xfa => { // f32
if (data.len < 5) {
return error.EOSReadingFloat;
}
try writer.print("{d}", .{@as(f32, @bitCast(std.mem.readInt(u32, data[1..5], .big)))});
return data[5..];
},
0xfb => { // f64
if (data.len < 9) {
return error.EOSReadingFloat;
}
try writer.print("{d}", .{@as(f64, @bitCast(std.mem.readInt(u64, data[1..9], .big)))});
return data[9..];
},
else => |b| {
const major_type = b >> 5;
switch (major_type) {
0 => {
const rest, const length = try parseLength(data);
try writer.print("{d}", .{length});
return rest;
},
1 => {
const rest, const length = try parseLength(data);
try writer.print("{d}", .{-@as(i64, @intCast(length)) - 1});
return rest;
},
2 => {
const rest, const str = try parseString(data);
try writer.writeByte('"');
try std.base64.standard.Encoder.encodeWriter(writer, str);
try writer.writeByte('"');
return rest;
},
3 => {
const rest, const str = try parseString(data);
try std.json.encodeJsonString(str, .{}, writer);
return rest;
},
// 4 => unreachable, // fixed-length array
// 5 => unreachable, // fixed-length map
else => return error.UnknownTag,
}
},
}
}
// We expect every array from V8 to be an infinite-length array. That it, it
// starts with the special tag: (4<<5) | 31 which an "array" with infinite
// length.
// Of course, it isn't infite, the end of the array happens when we hit a break
// code which is FF (7 << 5) | 31
fn writeInfiniteArray(d: []const u8, writer: anytype) ![]const u8 {
if (d.len == 0) {
return error.EOSReadingArray;
}
if (d[0] == 255) {
try writer.writeAll("[]");
return d[1..];
}
try writer.writeByte('[');
var data = try writeValue(d, writer);
while (data.len > 0) {
if (data[0] == 255) {
try writer.writeByte(']');
return data[1..];
}
try writer.writeByte(',');
data = try writeValue(data, writer);
}
// Reaching the end of the input is a mistake, should have reached the break
// code
return error.UnterminatedArray;
}
// We expect every map from V8 to be an infinite-length map. That it, it
// starts with the special tag: (5<<5) | 31 which an "map" with infinite
// length.
// Of course, it isn't infite, the end of the map happens when we hit a break
// code which is FF (7 << 5) | 31
fn writeInfiniteMap(d: []const u8, writer: anytype) ![]const u8 {
if (d.len == 0) {
return error.EOSReadingMap;
}
if (d[0] == 255) {
try writer.writeAll("{}");
return d[1..];
}
try writer.writeByte('{');
var data = blk: {
const data, const field = try maybeParseString(d);
try std.json.encodeJsonString(field, .{}, writer);
try writer.writeByte(':');
break :blk try writeValue(data, writer);
};
while (data.len > 0) {
if (data[0] == 255) {
try writer.writeByte('}');
return data[1..];
}
try writer.writeByte(',');
data, const field = try maybeParseString(data);
try std.json.encodeJsonString(field, .{}, writer);
try writer.writeByte(':');
data = try writeValue(data, writer);
}
// Reaching the end of the input is a mistake, should have reached the break
// code
return error.UnterminatedMap;
}
fn parseLength(data: []const u8) !struct { []const u8, usize } {
std.debug.assert(data.len > 0);
switch (data[0] & 0b11111) {
0...23 => |n| return .{ data[1..], n },
24 => {
if (data.len == 1) {
return error.EOSReadingLength;
}
return .{ data[2..], @intCast(data[1]) };
},
25 => {
if (data.len < 3) {
return error.EOSReadingLength;
}
return .{ data[3..], @intCast(std.mem.readInt(u16, data[1..3], .big)) };
},
26 => {
if (data.len < 5) {
return error.EOSReadingLength;
}
return .{ data[5..], @intCast(std.mem.readInt(u32, data[1..5], .big)) };
},
27 => {
if (data.len < 9) {
return error.EOSReadingLength;
}
return .{ data[9..], @intCast(std.mem.readInt(u64, data[1..9], .big)) };
},
else => return error.InvalidLength,
}
}
fn parseString(data: []const u8) !struct { []const u8, []const u8 } {
const rest, const length = try parseLength(data);
if (rest.len < length) {
return error.MissingData;
}
return .{ rest[length..], rest[0..length] };
}
fn maybeParseString(data: []const u8) !struct { []const u8, []const u8 } {
if (data.len == 0) {
return error.EOSExpectedString;
}
const b = data[0];
if (b >> 5 != 3) {
return error.ExpectedString;
}
return parseString(data);
}

View File

@@ -1,173 +0,0 @@
// 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 json = std.json;
const Allocator = std.mem.Allocator;
const Error = error{
InvalidJson,
OutOfMemory,
SyntaxError,
UnexpectedEndOfInput,
ValueTooLong,
};
pub fn jsonToCbor(arena: Allocator, input: []const u8) ![]const u8 {
var scanner = json.Scanner.initCompleteInput(arena, input);
defer scanner.deinit();
var arr: std.ArrayListUnmanaged(u8) = .empty;
try writeNext(arena, &arr, &scanner);
return arr.items;
}
fn writeNext(arena: Allocator, arr: *std.ArrayListUnmanaged(u8), scanner: *json.Scanner) Error!void {
const token = scanner.nextAlloc(arena, .alloc_if_needed) catch return error.InvalidJson;
return writeToken(arena, arr, scanner, token);
}
fn writeToken(arena: Allocator, arr: *std.ArrayListUnmanaged(u8), scanner: *json.Scanner, token: json.Token) Error!void {
switch (token) {
.object_begin => return writeObject(arena, arr, scanner),
.array_begin => return writeArray(arena, arr, scanner),
.true => return arr.append(arena, 7 << 5 | 21),
.false => return arr.append(arena, 7 << 5 | 20),
.null => return arr.append(arena, 7 << 5 | 22),
.allocated_string, .string => |key| return writeString(arena, arr, key),
.allocated_number, .number => |s| {
if (json.isNumberFormattedLikeAnInteger(s)) {
return writeInteger(arena, arr, s);
}
const f = std.fmt.parseFloat(f64, s) catch unreachable;
return writeHeader(arena, arr, 7, @intCast(@as(u64, @bitCast(f))));
},
else => unreachable,
}
}
fn writeObject(arena: Allocator, arr: *std.ArrayListUnmanaged(u8), scanner: *json.Scanner) !void {
const envelope = try startEmbeddedMessage(arena, arr);
// MajorType 5 (map) | 5-byte infinite length
try arr.append(arena, 5 << 5 | 31);
while (true) {
switch (try scanner.nextAlloc(arena, .alloc_if_needed)) {
.allocated_string, .string => |key| {
try writeString(arena, arr, key);
try writeNext(arena, arr, scanner);
},
.object_end => {
// MajorType 7 (break) | 5-byte infinite length
try arr.append(arena, 7 << 5 | 31);
return finalizeEmbeddedMessage(arr, envelope);
},
else => return error.InvalidJson,
}
}
}
fn writeArray(arena: Allocator, arr: *std.ArrayListUnmanaged(u8), scanner: *json.Scanner) !void {
const envelope = try startEmbeddedMessage(arena, arr);
// MajorType 4 (array) | 5-byte infinite length
try arr.append(arena, 4 << 5 | 31);
while (true) {
const token = scanner.nextAlloc(arena, .alloc_if_needed) catch return error.InvalidJson;
switch (token) {
.array_end => {
// MajorType 7 (break) | 5-byte infinite length
try arr.append(arena, 7 << 5 | 31);
return finalizeEmbeddedMessage(arr, envelope);
},
else => try writeToken(arena, arr, scanner, token),
}
}
}
fn writeString(arena: Allocator, arr: *std.ArrayListUnmanaged(u8), value: []const u8) !void {
try writeHeader(arena, arr, 3, value.len);
return arr.appendSlice(arena, value);
}
fn writeInteger(arena: Allocator, arr: *std.ArrayListUnmanaged(u8), s: []const u8) !void {
const n = std.fmt.parseInt(i64, s, 10) catch {
return error.InvalidJson;
};
if (n >= 0) {
return writeHeader(arena, arr, 0, @intCast(n));
}
return writeHeader(arena, arr, 1, @intCast(-1 - n));
}
fn writeHeader(arena: Allocator, arr: *std.ArrayListUnmanaged(u8), comptime typ: u8, count: usize) !void {
switch (count) {
0...23 => try arr.append(arena, typ << 5 | @as(u8, @intCast(count))),
24...255 => {
try arr.ensureUnusedCapacity(arena, 2);
arr.appendAssumeCapacity(typ << 5 | 24);
arr.appendAssumeCapacity(@intCast(count));
},
256...65535 => {
try arr.ensureUnusedCapacity(arena, 3);
arr.appendAssumeCapacity(typ << 5 | 25);
arr.appendAssumeCapacity(@intCast((count >> 8) & 0xff));
arr.appendAssumeCapacity(@intCast(count & 0xff));
},
65536...4294967295 => {
try arr.ensureUnusedCapacity(arena, 5);
arr.appendAssumeCapacity(typ << 5 | 26);
arr.appendAssumeCapacity(@intCast((count >> 24) & 0xff));
arr.appendAssumeCapacity(@intCast((count >> 16) & 0xff));
arr.appendAssumeCapacity(@intCast((count >> 8) & 0xff));
arr.appendAssumeCapacity(@intCast(count & 0xff));
},
else => {
try arr.ensureUnusedCapacity(arena, 9);
arr.appendAssumeCapacity(typ << 5 | 27);
arr.appendAssumeCapacity(@intCast((count >> 56) & 0xff));
arr.appendAssumeCapacity(@intCast((count >> 48) & 0xff));
arr.appendAssumeCapacity(@intCast((count >> 40) & 0xff));
arr.appendAssumeCapacity(@intCast((count >> 32) & 0xff));
arr.appendAssumeCapacity(@intCast((count >> 24) & 0xff));
arr.appendAssumeCapacity(@intCast((count >> 16) & 0xff));
arr.appendAssumeCapacity(@intCast((count >> 8) & 0xff));
arr.appendAssumeCapacity(@intCast(count & 0xff));
},
}
}
// I don't know why, but V8 expects any array or map (including the outer-most
// object), to be encoded as embedded cbor data. This is CBOR that contains CBOR.
// I feel that it's fine that it supports it, but why _require_ it? Seems like
// a waste of 7 bytes.
fn startEmbeddedMessage(arena: Allocator, arr: *std.ArrayListUnmanaged(u8)) !usize {
try arr.appendSlice(arena, &.{ 0xd8, 0x18, 0x5a, 0, 0, 0, 0 });
return arr.items.len;
}
fn finalizeEmbeddedMessage(arr: *std.ArrayListUnmanaged(u8), pos: usize) !void {
var items = arr.items;
const length = items.len - pos;
items[pos - 4] = @intCast((length >> 24) & 0xff);
items[pos - 3] = @intCast((length >> 16) & 0xff);
items[pos - 2] = @intCast((length >> 8) & 0xff);
items[pos - 1] = @intCast(length & 0xff);
}

View File

@@ -17,15 +17,12 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const json = std.json;
const Allocator = std.mem.Allocator;
const json = std.json;
const log = @import("../log.zig");
const cbor = @import("cbor/cbor.zig");
const App = @import("../app.zig").App;
const Env = @import("../browser/env.zig").Env;
const asUint = @import("../str/parser.zig").asUint;
const Browser = @import("../browser/browser.zig").Browser;
const Session = @import("../browser/session.zig").Session;
const Page = @import("../browser/page.zig").Page;
@@ -184,41 +181,42 @@ pub fn CDPT(comptime TypeProvider: type) type {
switch (domain.len) {
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
asUint("DOM") => return @import("domains/dom.zig").processMessage(command),
asUint("Log") => return @import("domains/log.zig").processMessage(command),
asUint("CSS") => return @import("domains/css.zig").processMessage(command),
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
else => {},
},
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
asUint("Page") => return @import("domains/page.zig").processMessage(command),
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
else => {},
},
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint("Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint("Input") => return @import("domains/input.zig").processMessage(command),
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
asUint("Target") => return @import("domains/target.zig").processMessage(command),
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
else => {},
},
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
asUint("Browser") => return @import("domains/browser.zig").processMessage(command),
asUint("Runtime") => return @import("domains/runtime.zig").processMessage(command),
asUint("Network") => return @import("domains/network.zig").processMessage(command),
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].*))) {
asUint("Security") => return @import("domains/security.zig").processMessage(command),
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
else => {},
},
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
asUint("Emulation") => return @import("domains/emulation.zig").processMessage(command),
asUint("Inspector") => return @import("domains/inspector.zig").processMessage(command),
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
else => {},
},
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
asUint("Performance") => return @import("domains/performance.zig").processMessage(command),
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
else => {},
},
else => {},
@@ -322,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();
@@ -376,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 {
@@ -414,11 +415,13 @@ pub fn BrowserContext(comptime CDP_T: type) type {
}
pub fn networkEnable(self: *Self) !void {
try self.cdp.browser.notification.register(.http_request_fail, self, onHttpRequestFail);
try self.cdp.browser.notification.register(.http_request_start, self, onHttpRequestStart);
try self.cdp.browser.notification.register(.http_request_complete, self, onHttpRequestComplete);
}
pub fn networkDisable(self: *Self) void {
self.cdp.browser.notification.unregister(.http_request_fail, self);
self.cdp.browser.notification.unregister(.http_request_start, self);
self.cdp.browser.notification.unregister(.http_request_complete, self);
}
@@ -450,6 +453,12 @@ pub fn BrowserContext(comptime CDP_T: type) type {
return @import("domains/network.zig").httpRequestStart(self.notification_arena, self, data);
}
pub fn onHttpRequestFail(ctx: *anyopaque, data: *const Notification.RequestFail) !void {
const self: *Self = @alignCast(@ptrCast(ctx));
defer self.resetNotificationArena();
return @import("domains/network.zig").httpRequestFail(self.notification_arena, self, data);
}
pub fn onHttpRequestComplete(ctx: *anyopaque, data: *const Notification.RequestComplete) !void {
const self: *Self = @alignCast(@ptrCast(ctx));
defer self.resetNotificationArena();
@@ -460,21 +469,31 @@ pub fn BrowserContext(comptime CDP_T: type) type {
defer _ = self.cdp.notification_arena.reset(.{ .retain_with_limit = 1024 * 64 });
}
pub fn callInspector(self: *const Self, arena: Allocator, input: []const u8) !void {
const encoded = try cbor.jsonToCbor(arena, input);
try self.inspector.send(encoded);
pub fn callInspector(self: *const Self, msg: []const u8) void {
self.inspector.send(msg);
// force running micro tasks after send input to the inspector.
self.cdp.browser.runMicrotasks();
}
pub fn onInspectorResponse(ctx: *anyopaque, _: u32, str: Env.Inspector.StringView) void {
sendInspectorMessage(@alignCast(@ptrCast(ctx)), str) catch |err| {
pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
log.err(.cdp, "send inspector response", .{ .err = err });
};
}
pub fn onInspectorEvent(ctx: *anyopaque, str: Env.Inspector.StringView) void {
sendInspectorMessage(@alignCast(@ptrCast(ctx)), str) catch |err| {
pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void {
if (log.enabled(.cdp, .debug)) {
// msg should be {"method":<method>,...
std.debug.assert(std.mem.startsWith(u8, msg, "{\"method\":"));
const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
log.err(.cdp, "invalid inspector event", .{ .msg = msg });
return;
};
const method = msg[10..method_end];
log.debug(.cdp, "inspector event", .{ .method = method });
}
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
log.err(.cdp, "send inspector event", .{ .err = err });
};
}
@@ -482,7 +501,7 @@ pub fn BrowserContext(comptime CDP_T: type) type {
// This is hacky x 2. First, we create the JSON payload by gluing our
// session_id onto it. Second, we're much more client/websocket aware than
// we should be.
fn sendInspectorMessage(self: *Self, str: Env.Inspector.StringView) !void {
fn sendInspectorMessage(self: *Self, msg: []const u8) !void {
const session_id = self.session_id orelse {
// We no longer have an active session. What should we do
// in this case?
@@ -493,26 +512,27 @@ pub fn BrowserContext(comptime CDP_T: type) type {
var arena = std.heap.ArenaAllocator.init(cdp.allocator);
errdefer arena.deinit();
const aa = arena.allocator();
const field = ",\"sessionId\":\"";
// + 1 for the closing quote after the session id
// + 10 for the max websocket header
const message_len = msg.len + session_id.len + 1 + field.len + 10;
var buf: std.ArrayListUnmanaged(u8) = .{};
buf.ensureTotalCapacity(arena.allocator(), message_len) catch |err| {
log.err(.cdp, "inspector buffer", .{ .err = err });
return;
};
// reserve 10 bytes for websocket header
try buf.appendSlice(aa, &.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
buf.appendSliceAssumeCapacity(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
try cbor.cborToJson(str.bytes(), buf.writer(aa));
std.debug.assert(buf.getLast() == '}');
// We need to inject the session_id
// First, we strip out the closing '}'
buf.items.len -= 1;
// Next we inject the session id field + value
try buf.appendSlice(aa, ",\"sessionId\":\"");
try buf.appendSlice(aa, session_id);
// Finally, we re-close the object. Smooth.
try buf.appendSlice(aa, "\"}");
// -1 because we dont' want the closing brace '}'
buf.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]);
buf.appendSliceAssumeCapacity(field);
buf.appendSliceAssumeCapacity(session_id);
buf.appendSliceAssumeCapacity("\"}");
std.debug.assert(buf.items.len == message_len);
try cdp.client.sendJSONRaw(arena, buf);
}
@@ -538,8 +558,8 @@ const IsolatedWorld = struct {
self.executor.deinit();
}
pub fn removeContext(self: *IsolatedWorld) !void {
if (self.executor.scope == null) return error.NoIsolatedContextToRemove;
self.executor.endScope();
if (self.executor.js_context == null) return error.NoIsolatedContextToRemove;
self.executor.removeJsContext();
}
// The isolate world must share at least some of the state with the related page, specifically the DocumentHTML
@@ -548,8 +568,8 @@ const IsolatedWorld = struct {
// This also means this pointer becomes invalid after removePage untill a new page is created.
// Currently we have only 1 page/frame and thus also only 1 state in the isolate world.
pub fn createContext(self: *IsolatedWorld, page: *Page) !void {
if (self.executor.scope != null) return error.Only1IsolatedContextSupported;
_ = try self.executor.startScope(&page.window, page, {}, false);
if (self.executor.js_context != null) return error.Only1IsolatedContextSupported;
_ = try self.executor.createJsContext(&page.window, page, {}, false);
}
};
@@ -679,6 +699,10 @@ const InputParams = struct {
}
};
fn asUint(comptime T: type, comptime string: []const u8) T {
return @bitCast(string[0..string.len].*);
}
const testing = @import("testing.zig");
test "cdp: invalid json" {
var ctx = testing.context();

View File

@@ -259,13 +259,13 @@ fn resolveNode(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
var scope = page.scope;
var js_context = page.main_context;
if (params.executionContextId) |context_id| {
if (scope.context.debugContextId() != context_id) {
if (js_context.v8_context.debugContextId() != context_id) {
var isolated_world = bc.isolated_world orelse return error.ContextNotFound;
scope = &(isolated_world.executor.scope orelse return error.ContextNotFound);
js_context = &(isolated_world.executor.js_context orelse return error.ContextNotFound);
if (scope.context.debugContextId() != context_id) return error.ContextNotFound;
if (js_context.v8_context.debugContextId() != context_id) return error.ContextNotFound;
}
}
@@ -275,7 +275,7 @@ fn resolveNode(cmd: anytype) !void {
// node._node is a *parser.Node we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement
// So we use the Node.Union when retrieve the value from the environment
const remote_object = try bc.inspector.getRemoteObject(
scope,
js_context,
params.objectGroup orelse "",
try dom_node.Node.toInterface(node._node),
);
@@ -368,7 +368,7 @@ fn getNode(arena: Allocator, browser_context: anytype, node_id: ?Node.Id, backen
if (object_id) |object_id_| {
// Retrieve the object from which ever context it is in.
const parser_node = try browser_context.inspector.getNodePtr(arena, object_id_);
return try browser_context.node_registry.register(@ptrCast(parser_node));
return try browser_context.node_registry.register(@alignCast(@ptrCast(parser_node)));
}
return error.MissingParams;
}

View File

@@ -21,14 +21,60 @@ const Page = @import("../../browser/page.zig").Page;
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
dispatchKeyEvent,
dispatchMouseEvent,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.dispatchKeyEvent => return dispatchKeyEvent(cmd),
.dispatchMouseEvent => return dispatchMouseEvent(cmd),
}
}
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchKeyEvent
fn dispatchKeyEvent(cmd: anytype) !void {
const params = (try cmd.params(struct {
type: Type,
key: []const u8 = "",
code: []const u8 = "",
modifiers: u4 = 0,
// Many optional parameters are not implemented yet, see documentation url.
const Type = enum {
keyDown,
keyUp,
rawKeyDown,
char,
};
})) orelse return error.InvalidParams;
try cmd.sendResult(null, .{});
// quickly ignore types we know we don't handle
switch (params.type) {
.keyUp, .rawKeyDown, .char => return,
.keyDown => {},
}
const bc = cmd.browser_context orelse return;
const page = bc.session.currentPage() orelse return;
const keyboard_event = Page.KeyboardEvent{
.key = params.key,
.code = params.code,
.type = switch (params.type) {
.keyDown => .keydown,
else => unreachable,
},
.alt = params.modifiers & 1 == 1,
.ctrl = params.modifiers & 2 == 2,
.meta = params.modifiers & 4 == 4,
.shift = params.modifiers & 8 == 8,
};
try page.keyboardEvent(keyboard_event);
// result already sent
}
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent
fn dispatchMouseEvent(cmd: anytype) !void {
const params = (try cmd.params(struct {

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 {
@@ -84,6 +201,26 @@ fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: s
return true;
}
pub fn httpRequestFail(arena: Allocator, bc: anytype, request: *const Notification.RequestFail) !void {
// It's possible that the request failed because we aborted when the client
// sent Target.closeTarget. In that case, bc.session_id will be cleared
// already, and we can skip sending these messages to the client.
const session_id = bc.session_id orelse return;
// Isn't possible to do a network request within a Browser (which our
// notification is tied to), without a page.
std.debug.assert(bc.session.page != null);
// We're missing a bunch of fields, but, for now, this seems like enough
try bc.cdp.sendEvent("Network.loadingFailed", .{
.requestId = try std.fmt.allocPrint(arena, "REQ-{d}", .{request.id}),
// Seems to be what chrome answers with. I assume it depends on the type of error?
.type = "Ping",
.errorText = request.err,
.canceled = false,
}, .{ .session_id = session_id });
}
pub fn httpRequestStart(arena: Allocator, bc: anytype, request: *const Notification.RequestStart) !void {
// Isn't possible to do a network request within a Browser (which our
// notification is tied to), without a page.
@@ -215,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 });
}

View File

@@ -117,14 +117,14 @@ fn createIsolatedWorld(cmd: anytype) !void {
const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
try pageCreated(bc, page);
const scope = &world.executor.scope.?;
const js_context = &world.executor.js_context.?;
// Create the auxdata json for the contextCreated event
// Calling contextCreated will assign a Id to the context and send the contextCreated event
const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId});
bc.inspector.contextCreated(scope, world.name, "", aux_data, false);
bc.inspector.contextCreated(js_context, world.name, "", aux_data, false);
return cmd.sendResult(.{ .executionContextId = scope.context.debugContextId() }, .{});
return cmd.sendResult(.{ .executionContextId = js_context.v8_context.debugContextId() }, .{});
}
fn navigate(cmd: anytype) !void {
@@ -163,6 +163,11 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
std.debug.assert(bc.session.page != null);
var cdp = bc.cdp;
if (event.opts.reason != .address_bar) {
bc.loader_id = bc.cdp.loader_id_gen.next();
}
const loader_id = bc.loader_id;
const target_id = bc.target_id orelse unreachable;
const session_id = bc.session_id orelse unreachable;
@@ -248,7 +253,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id});
bc.inspector.contextCreated(
page.scope,
page.main_context,
"",
try page.origin(arena),
aux_data,
@@ -259,7 +264,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{target_id});
// Calling contextCreated will assign a new Id to the context and send the contextCreated event
bc.inspector.contextCreated(
&isolated_world.executor.scope.?,
&isolated_world.executor.js_context.?,
isolated_world.name,
"://",
aux_json,
@@ -281,7 +286,7 @@ pub fn pageCreated(bc: anytype, page: *Page) !void {
try isolated_world.createContext(page);
const polyfill = @import("../../browser/polyfill/polyfill.zig");
try polyfill.load(bc.arena, &isolated_world.executor.scope.?);
try polyfill.load(bc.arena, &isolated_world.executor.js_context.?);
}
}

View File

@@ -44,7 +44,7 @@ fn sendInspector(cmd: anytype, action: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
// the result to return is handled directly by the inspector.
return bc.callInspector(cmd.arena, cmd.input.json);
bc.callInspector(cmd.input.json);
}
fn logInspector(cmd: anytype, action: anytype) !void {

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,
}, .{});
@@ -127,7 +146,7 @@ fn createTarget(cmd: anytype) !void {
{
const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id});
bc.inspector.contextCreated(
page.scope,
page.main_context,
"",
try page.origin(cmd.arena),
aux_data,
@@ -220,7 +239,7 @@ fn closeTarget(cmd: anytype) !void {
bc.session_id = null;
}
try bc.session.removePage();
bc.session.removePage();
if (bc.isolated_world) |*world| {
world.deinit();
bc.isolated_world = null;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -146,6 +146,16 @@ fn logTo(comptime scope: Scope, level: Level, comptime msg: []const u8, data: an
}
fn logLogfmt(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, writer: anytype) !void {
try logLogFmtPrefix(scope, level, msg, writer);
inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |f| {
const key = " " ++ f.name ++ "=";
try writer.writeAll(key);
try writeValue(.logfmt, @field(data, f.name), writer);
}
try writer.writeByte('\n');
}
fn logLogFmtPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8, writer: anytype) !void {
try writer.writeAll("$time=");
try writer.print("{d}", .{timestamp()});
@@ -164,15 +174,20 @@ fn logLogfmt(comptime scope: Scope, level: Level, comptime msg: []const u8, data
break :blk prefix ++ "\"" ++ msg ++ "\"";
};
try writer.writeAll(full_msg);
}
fn logPretty(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, writer: anytype) !void {
try logPrettyPrefix(scope, level, msg, writer);
inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |f| {
const key = " " ++ f.name ++ "=";
const key = " " ++ f.name ++ " = ";
try writer.writeAll(key);
try writeValue(.logfmt, @field(data, f.name), writer);
try writeValue(.pretty, @field(data, f.name), writer);
try writer.writeByte('\n');
}
try writer.writeByte('\n');
}
fn logPretty(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, writer: anytype) !void {
fn logPrettyPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8, writer: anytype) !void {
if (scope == .console and level == .fatal and comptime std.mem.eql(u8, msg, "lightpanda")) {
try writer.writeAll("\x1b[0;104mWARN ");
} else {
@@ -201,14 +216,6 @@ fn logPretty(comptime scope: Scope, level: Level, comptime msg: []const u8, data
try writer.print(" \x1b[0m[+{d}ms]", .{elapsed()});
try writer.writeByte('\n');
}
inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |f| {
const key = " " ++ f.name ++ " = ";
try writer.writeAll(key);
try writeValue(.pretty, @field(data, f.name), writer);
try writer.writeByte('\n');
}
try writer.writeByte('\n');
}
pub fn writeValue(comptime format: Format, value: anytype, writer: anytype) !void {

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,58 +649,81 @@ 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 request = http_server.receiveHead() catch |err| switch (err) {
error.HttpConnectionClosing => continue :ACCEPT,
else => {
std.debug.print("Test HTTP Server error: {}\n", .{err});
return err;
},
};
const path = request.head.target;
if (std.mem.eql(u8, path, "/loader")) {
try request.respond("Hello!", .{
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
});
} else if (std.mem.eql(u8, path, "/http_client/simple")) {
try request.respond("", .{
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
});
} else if (std.mem.eql(u8, path, "/http_client/redirect")) {
try request.respond("", .{
.status = .moved_permanently,
.extra_headers = &.{
.{ .name = "Connection", .value = "close" },
.{ .name = "LOCATION", .value = "../http_client/echo" },
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 => {
std.debug.print("Test HTTP Server error: {}\n", .{err});
return err;
},
});
} else if (std.mem.eql(u8, path, "/http_client/redirect/secure")) {
try request.respond("", .{
.status = .moved_permanently,
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "LOCATION", .value = "https://127.0.0.1:9581/http_client/body" } },
});
} else if (std.mem.eql(u8, path, "/http_client/gzip")) {
const body = &.{ 0x1f, 0x8b, 0x08, 0x08, 0x01, 0xc6, 0x19, 0x68, 0x00, 0x03, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x68, 0x74, 0x6d, 0x6c, 0x00, 0x73, 0x54, 0xc8, 0x4b, 0x2d, 0x57, 0x48, 0x2a, 0xca, 0x2f, 0x2f, 0x4e, 0x2d, 0x52, 0x48, 0x2a, 0xcd, 0xcc, 0x29, 0x51, 0x48, 0xcb, 0x2f, 0x52, 0xc8, 0x4d, 0x4c, 0xce, 0xc8, 0xcc, 0x4b, 0x2d, 0xe6, 0x02, 0x00, 0xe7, 0xc3, 0x4b, 0x27, 0x21, 0x00, 0x00, 0x00 };
try request.respond(body, .{
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "Content-Encoding", .value = "gzip" } },
});
} else if (std.mem.eql(u8, path, "/http_client/echo")) {
var headers: std.ArrayListUnmanaged(std.http.Header) = .{};
};
var it = request.iterateHeaders();
while (it.next()) |hdr| {
try headers.append(aa, .{
.name = try std.fmt.allocPrint(aa, "_{s}", .{hdr.name}),
.value = hdr.value,
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!", .{
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
});
} else if (std.mem.eql(u8, path, "/http_client/simple")) {
try request.respond("", .{
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
});
} else if (std.mem.eql(u8, path, "/http_client/redirect")) {
try request.respond("", .{
.status = .moved_permanently,
.extra_headers = &.{
.{ .name = "Connection", .value = "close" },
.{ .name = "LOCATION", .value = "../http_client/echo" },
},
});
} else if (std.mem.eql(u8, path, "/http_client/redirect/secure")) {
try request.respond("", .{
.status = .moved_permanently,
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "LOCATION", .value = "https://127.0.0.1:9581/http_client/body" } },
});
} else if (std.mem.eql(u8, path, "/http_client/gzip")) {
const body = &.{ 0x1f, 0x8b, 0x08, 0x08, 0x01, 0xc6, 0x19, 0x68, 0x00, 0x03, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x68, 0x74, 0x6d, 0x6c, 0x00, 0x73, 0x54, 0xc8, 0x4b, 0x2d, 0x57, 0x48, 0x2a, 0xca, 0x2f, 0x2f, 0x4e, 0x2d, 0x52, 0x48, 0x2a, 0xcd, 0xcc, 0x29, 0x51, 0x48, 0xcb, 0x2f, 0x52, 0xc8, 0x4d, 0x4c, 0xce, 0xc8, 0xcc, 0x4b, 0x2d, 0xe6, 0x02, 0x00, 0xe7, 0xc3, 0x4b, 0x27, 0x21, 0x00, 0x00, 0x00 };
try request.respond(body, .{
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "Content-Encoding", .value = "gzip" } },
});
} else if (std.mem.eql(u8, path, "/http_client/echo")) {
var headers: std.ArrayListUnmanaged(std.http.Header) = .{};
var it = request.iterateHeaders();
while (it.next()) |hdr| {
try headers.append(aa, .{
.name = try std.fmt.allocPrint(aa, "_{s}", .{hdr.name}),
.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!", .{
.status = .created,
.extra_headers = headers.items,
});
}
try headers.append(aa, .{ .name = "Connection", .value = "Close" });
try request.respond("over 9000!", .{
.status = .created,
.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,10 +122,11 @@ 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();
try polyfill.load(arena, runner.page.scope);
try polyfill.load(arena, runner.page.main_context);
// loop over the scripts.
const doc = parser.documentHTMLToDocument(runner.page.window.document);
@@ -155,9 +168,9 @@ fn run(arena: Allocator, test_file: []const u8, loader: *FileLoader, err_out: *?
{
// wait for all async executions
var try_catch: Env.TryCatch = undefined;
try_catch.init(runner.page.scope);
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

@@ -59,6 +59,7 @@ pub const Notification = struct {
page_created: List = .{},
page_navigate: List = .{},
page_navigated: List = .{},
http_request_fail: List = .{},
http_request_start: List = .{},
http_request_complete: List = .{},
notification_created: List = .{},
@@ -69,6 +70,7 @@ pub const Notification = struct {
page_created: *page.Page,
page_navigate: *const PageNavigate,
page_navigated: *const PageNavigated,
http_request_fail: *const RequestFail,
http_request_start: *const RequestStart,
http_request_complete: *const RequestComplete,
notification_created: *Notification,
@@ -97,6 +99,12 @@ pub const Notification = struct {
has_body: bool,
};
pub const RequestFail = struct {
id: usize,
url: *const std.Uri,
err: []const u8,
};
pub const RequestComplete = struct {
id: usize,
url: *const std.Uri,

File diff suppressed because it is too large Load Diff

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.
@@ -81,12 +83,13 @@ pub const Loop = struct {
// run tail events. We do run the tail events to ensure all the
// contexts are correcly free.
while (self.hasPendinEvents()) {
self.io.run_for_ns(10 * std.time.ns_per_ms) catch |err| {
while (self.pending_network_count != 0 or self.pending_timeout_count != 0) {
self.io.run_for_ns(RUN_DURATION) catch |err| {
log.err(.loop, "deinit", .{ .err = err });
break;
};
}
if (comptime CANCEL_SUPPORTED) {
self.io.cancel_all();
}
@@ -96,34 +99,25 @@ pub const Loop = struct {
self.cancelled.deinit(self.alloc);
}
// We can shutdown once all the pending network IO is complete.
// In debug mode we also wait until al the pending timeouts are complete
// but we only do this so that the `timeoutCallback` can free all allocated
// memory and we won't report a leak.
fn hasPendinEvents(self: *const Self) bool {
if (self.pending_network_count > 0) {
return true;
}
if (builtin.mode != .Debug) {
return false;
}
return self.pending_timeout_count > 0;
}
// Retrieve all registred I/O events completed by OS kernel,
// and execute sequentially their callbacks.
// 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) {
try self.io.run_for_ns(10 * std.time.ns_per_ms);
// at each iteration we might have new events registred by previous callbacks
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;
};
}
}
@@ -199,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

@@ -77,6 +77,117 @@ pub const MyAPI = struct {
}
};
pub const Parent = packed struct {
parent_id: i32 = 0,
pub fn get_parent(self: *const Parent) i32 {
return self.parent_id;
}
pub fn set_parent(self: *Parent, id: i32) void {
self.parent_id = id;
}
};
pub const Middle = struct {
pub const prototype = *Parent;
middle_id: i32 = 0,
_padding_1: u8 = 0,
_padding_2: u8 = 1,
_padding_3: u8 = 2,
proto: Parent,
pub fn constructor() Middle {
return .{
.middle_id = 0,
.proto = .{ .parent_id = 0 },
};
}
pub fn get_middle(self: *const Middle) i32 {
return self.middle_id;
}
pub fn set_middle(self: *Middle, id: i32) void {
self.middle_id = id;
}
};
pub const Child = struct {
pub const prototype = *Middle;
child_id: i32 = 0,
_padding_1: u8 = 0,
proto: Middle,
pub fn constructor() Child {
return .{
.child_id = 0,
.proto = .{ .middle_id = 0, .proto = .{ .parent_id = 0 } },
};
}
pub fn get_child(self: *const Child) i32 {
return self.child_id;
}
pub fn set_child(self: *Child, id: i32) void {
self.child_id = id;
}
};
pub const MiddlePtr = packed struct {
pub const prototype = *Parent;
middle_id: i32 = 0,
_padding_1: u8 = 0,
_padding_2: u8 = 1,
_padding_3: u8 = 2,
proto: *Parent,
pub fn constructor(state: State) !MiddlePtr {
const parent = try state.arena.create(Parent);
parent.* = .{ .parent_id = 0 };
return .{
.middle_id = 0,
.proto = parent,
};
}
pub fn get_middle(self: *const MiddlePtr) i32 {
return self.middle_id;
}
pub fn set_middle(self: *MiddlePtr, id: i32) void {
self.middle_id = id;
}
};
pub const ChildPtr = packed struct {
pub const prototype = *MiddlePtr;
child_id: i32 = 0,
_padding_1: u8 = 0,
_padding_2: u8 = 1,
proto: *MiddlePtr,
pub fn constructor(state: State) !ChildPtr {
const parent = try state.arena.create(Parent);
const middle = try state.arena.create(MiddlePtr);
parent.* = .{ .parent_id = 0 };
middle.* = .{ .middle_id = 0, .proto = parent };
return .{
.child_id = 0,
.proto = middle,
};
}
pub fn get_child(self: *const ChildPtr) i32 {
return self.child_id;
}
pub fn set_child(self: *ChildPtr, id: i32) void {
self.child_id = id;
}
};
const State = struct {
arena: Allocator,
};
@@ -90,6 +201,11 @@ test "JS: object types" {
Other,
MyObject,
MyAPI,
Parent,
Middle,
Child,
MiddlePtr,
ChildPtr,
}).init(.{ .arena = arena.allocator() }, {});
defer runner.deinit();
@@ -120,4 +236,40 @@ test "JS: object types" {
// check object property
.{ "myObjIndirect.a.val()", "4" },
}, .{});
try runner.testCases(&.{
.{ "let m1 = new Middle();", null },
.{ "m1.middle = 2", null },
.{ "m1.parent = 3", null },
.{ "m1.middle", "2" },
.{ "m1.parent", "3" },
}, .{});
try runner.testCases(&.{
.{ "let c1 = new Child();", null },
.{ "c1.child = 1", null },
.{ "c1.middle = 2", null },
.{ "c1.parent = 3", null },
.{ "c1.child", "1" },
.{ "c1.middle", "2" },
.{ "c1.parent", "3" },
}, .{});
try runner.testCases(&.{
.{ "let m2 = new MiddlePtr();", null },
.{ "m2.middle = 2", null },
.{ "m2.parent = 3", null },
.{ "m2.middle", "2" },
.{ "m2.parent", "3" },
}, .{});
try runner.testCases(&.{
.{ "let c2 = new ChildPtr();", null },
.{ "c2.child = 1", null },
.{ "c2.middle = 2", null },
.{ "c2.parent = 3", null },
.{ "c2.child", "1" },
.{ "c2.middle", "2" },
.{ "c2.parent", "3" },
}, .{});
}

View File

@@ -29,7 +29,7 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty
return struct {
env: *Env,
scope: *Env.Scope,
js_context: *Env.JsContext,
executor: Env.ExecutionWorld,
pub const Env = js.Env(State, struct {
@@ -42,13 +42,13 @@ 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();
errdefer self.executor.deinit();
self.scope = try self.executor.startScope(
self.js_context = try self.executor.createJsContext(
if (Global == void) &default_global else global,
state,
{},
@@ -68,10 +68,10 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty
pub fn testCases(self: *Self, cases: []const Case, _: RunOpts) !void {
for (cases, 0..) |case, i| {
var try_catch: Env.TryCatch = undefined;
try_catch.init(self.scope);
try_catch.init(self.js_context);
defer try_catch.deinit();
const value = self.scope.exec(case.@"0", null) catch |err| {
const value = self.js_context.exec(case.@"0", null) catch |err| {
if (try try_catch.err(allocator)) |msg| {
defer allocator.free(msg);
if (isExpectedTypeError(case.@"1", msg)) {

View File

@@ -39,7 +39,7 @@ const CDP = @import("cdp/cdp.zig").CDP;
const TimeoutCheck = std.time.ns_per_ms * 100;
const MAX_HTTP_REQUEST_SIZE = 2048;
const MAX_HTTP_REQUEST_SIZE = 4096;
// max message size
// +14 for max websocket payload overhead
@@ -223,7 +223,7 @@ pub const Client = struct {
}
fn close(self: *Self) void {
log.info(.app, "client disconected", .{});
log.info(.app, "client disconnected", .{});
self.connected = false;
// recv only, because we might have pending writes we'd like to get
// out (like the HTTP error response)
@@ -1142,7 +1142,7 @@ test "Client: http invalid request" {
var c = try createTestClient();
defer c.deinit();
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 2050) ++ "\r\n\r\n");
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n");
try testing.expectEqualStrings("HTTP/1.1 413 \r\n" ++
"Connection: Close\r\n" ++
"Content-Length: 17\r\n\r\n" ++

View File

@@ -1,125 +0,0 @@
// 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/>.
// some utils to parser strings.
const std = @import("std");
pub const Reader = struct {
pos: usize = 0,
data: []const u8,
pub fn until(self: *Reader, c: u8) []const u8 {
const pos = self.pos;
const data = self.data;
const index = std.mem.indexOfScalarPos(u8, data, pos, c) orelse data.len;
self.pos = index;
return data[pos..index];
}
pub fn tail(self: *Reader) []const u8 {
const pos = self.pos;
const data = self.data;
if (pos > data.len) {
return "";
}
self.pos = data.len;
return data[pos..];
}
pub fn skip(self: *Reader) bool {
const pos = self.pos;
if (pos >= self.data.len) {
return false;
}
self.pos = pos + 1;
return true;
}
};
// converts a comptime-known string (i.e. null terminated) to an uint
pub fn asUint(comptime string: anytype) AsUintReturn(string) {
const byteLength = @bitSizeOf(@TypeOf(string.*)) / 8 - 1;
const expectedType = *const [byteLength:0]u8;
if (@TypeOf(string) != expectedType) {
@compileError("expected : " ++ @typeName(expectedType) ++
", got: " ++ @typeName(@TypeOf(string)));
}
return @bitCast(@as(*const [byteLength]u8, string).*);
}
fn AsUintReturn(comptime string: anytype) type {
return @Type(.{
.int = .{
.bits = @bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0
.signedness = .unsigned,
},
});
}
const testing = std.testing;
test "parser.Reader: skip" {
var r = Reader{ .data = "foo" };
try testing.expectEqual(true, r.skip());
try testing.expectEqual(true, r.skip());
try testing.expectEqual(true, r.skip());
try testing.expectEqual(false, r.skip());
try testing.expectEqual(false, r.skip());
}
test "parser.Reader: tail" {
var r = Reader{ .data = "foo" };
try testing.expectEqualStrings("foo", r.tail());
try testing.expectEqualStrings("", r.tail());
try testing.expectEqualStrings("", r.tail());
}
test "parser.Reader: until" {
var r = Reader{ .data = "foo.bar.baz" };
try testing.expectEqualStrings("foo", r.until('.'));
_ = r.skip();
try testing.expectEqualStrings("bar", r.until('.'));
_ = r.skip();
try testing.expectEqualStrings("baz", r.until('.'));
r = Reader{ .data = "foo" };
try testing.expectEqualStrings("foo", r.until('.'));
try testing.expectEqualStrings("", r.tail());
r = Reader{ .data = "" };
try testing.expectEqualStrings("", r.until('.'));
try testing.expectEqualStrings("", r.tail());
}
test "parser: asUint" {
const ASCII_x = @as(u8, @bitCast([1]u8{'x'}));
const ASCII_ab = @as(u16, @bitCast([2]u8{ 'a', 'b' }));
const ASCII_xyz = @as(u24, @bitCast([3]u8{ 'x', 'y', 'z' }));
const ASCII_abcd = @as(u32, @bitCast([4]u8{ 'a', 'b', 'c', 'd' }));
try testing.expectEqual(ASCII_x, asUint("x"));
try testing.expectEqual(ASCII_ab, asUint("ab"));
try testing.expectEqual(ASCII_xyz, asUint("xyz"));
try testing.expectEqual(ASCII_abcd, asUint("abcd"));
try testing.expectEqual(u8, @TypeOf(asUint("x")));
try testing.expectEqual(u16, @TypeOf(asUint("ab")));
try testing.expectEqual(u24, @TypeOf(asUint("xyz")));
try testing.expectEqual(u32, @TypeOf(asUint("abcd")));
}

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();
@@ -419,23 +425,23 @@ pub const JsRunner = struct {
const RunOpts = struct {};
pub const Case = std.meta.Tuple(&.{ []const u8, ?[]const u8 });
pub fn testCases(self: *JsRunner, cases: []const Case, _: RunOpts) !void {
const scope = self.page.scope;
const js_context = self.page.main_context;
const arena = self.page.arena;
const start = try std.time.Instant.now();
for (cases, 0..) |case, i| {
var try_catch: Env.TryCatch = undefined;
try_catch.init(scope);
try_catch.init(js_context);
defer try_catch.deinit();
const value = scope.exec(case.@"0", null) catch |err| {
const value = js_context.exec(case.@"0", null) catch |err| {
if (try try_catch.err(arena)) |msg| {
std.debug.print("{s}\n\nCase: {d}\n{s}\n", .{ msg, i + 1, case.@"0" });
}
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| {
@@ -453,14 +459,14 @@ pub const JsRunner = struct {
}
pub fn eval(self: *JsRunner, src: []const u8, name: ?[]const u8, err_msg: *?[]const u8) !Env.Value {
const scope = self.page.scope;
const js_context = self.page.main_context;
const arena = self.page.arena;
var try_catch: Env.TryCatch = undefined;
try_catch.init(scope);
try_catch.init(js_context);
defer try_catch.deinit();
return scope.exec(src, name) catch |err| {
return js_context.exec(src, name) catch |err| {
if (try try_catch.err(arena)) |msg| {
err_msg.* = msg;
std.debug.print("Error running script: {s}\n", .{msg});
@@ -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" {